From d6b35cbb06b922c0f1f5f99dca2d425feb15024e Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Mon, 3 Feb 2025 16:18:12 -0800 Subject: [PATCH 01/11] watch commits independent of update and adds `effect` --- .../labs/signals/src/lib/signal-watcher.ts | 299 ++++++++++-------- packages/labs/signals/src/lib/watch.ts | 36 ++- .../signals/src/test/signal-watcher_test.ts | 190 ++++++++++- packages/labs/signals/src/test/watch_test.ts | 161 +++++++++- 4 files changed, 537 insertions(+), 149 deletions(-) diff --git a/packages/labs/signals/src/lib/signal-watcher.ts b/packages/labs/signals/src/lib/signal-watcher.ts index 4a8b2e662b..813359f1df 100644 --- a/packages/labs/signals/src/lib/signal-watcher.ts +++ b/packages/labs/signals/src/lib/signal-watcher.ts @@ -4,23 +4,31 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import type {PropertyDeclaration, PropertyValueMap, ReactiveElement} from 'lit'; +import type {ReactiveElement} from 'lit'; import {Signal} from 'signal-polyfill'; -import {WatchDirective} from './watch.js'; - -type ReactiveElementConstructor = abstract new ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...args: any[] -) => ReactiveElement; export interface SignalWatcher extends ReactiveElement { - _updateWatchDirective(d: WatchDirective): void; - _clearWatchDirective(d: WatchDirective): void; + _partUpdateWatcher?: Signal.subtle.Watcher; +} + +interface SignalWatcherApi { + effect( + fn: () => void, + options?: {beforeUpdate?: boolean; manualDispose?: boolean} + ): () => void; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Constructor = new (...args: any[]) => T; + interface SignalWatcherInterface extends SignalWatcher {} interface SignalWatcherInternal extends SignalWatcher { __forcingUpdate: boolean; + __beforeUpdateWatcher?: Signal.subtle.Watcher; + __performUpdateWatcher?: Signal.subtle.Watcher; + __afterUpdateWatcher?: Signal.subtle.Watcher; + __flushEffects: () => void; + __queueEffects: () => void; } const signalWatcherBrand: unique symbol = Symbol('SignalWatcherBrand'); @@ -32,11 +40,31 @@ const signalWatcherBrand: unique symbol = Symbol('SignalWatcherBrand'); // clean up the watcher when the element is garbage collected. const elementFinalizationRegistry = new FinalizationRegistry<{ - watcher: Signal.subtle.Watcher; - signal: Signal.Computed; -}>(({watcher, signal}) => { - watcher.unwatch(signal); -}); + beforeUpdateWatcher: Signal.subtle.Watcher; + performUpdateWatcher: Signal.subtle.Watcher; + partUpdateWatcher: Signal.subtle.Watcher; + afterUpdateWatcher: Signal.subtle.Watcher; +}>( + ({ + beforeUpdateWatcher, + performUpdateWatcher, + partUpdateWatcher, + afterUpdateWatcher, + }) => { + beforeUpdateWatcher.unwatch( + ...Signal.subtle.introspectSources(beforeUpdateWatcher) + ); + performUpdateWatcher.unwatch( + ...Signal.subtle.introspectSources(performUpdateWatcher) + ); + partUpdateWatcher.unwatch( + ...Signal.subtle.introspectSources(partUpdateWatcher) + ); + afterUpdateWatcher.unwatch( + ...Signal.subtle.introspectSources(afterUpdateWatcher) + ); + } +); const elementForWatcher = new WeakMap< Signal.subtle.Watcher, @@ -48,24 +76,55 @@ const elementForWatcher = new WeakMap< * watch for access to signals during the update lifecycle and trigger a new * update when signals values change. */ -export function SignalWatcher( - Base: T -): T { +export function SignalWatcher>(Base: T) { // Only apply the mixin once if ((Base as typeof SignalWatcher)[signalWatcherBrand] === true) { console.warn( 'SignalWatcher should not be applied to the same class more than once.' ); - return Base; + return Base as T & Constructor; } - abstract class SignalWatcher extends Base implements SignalWatcherInterface { + class SignalWatcher extends Base implements SignalWatcherInterface { static [signalWatcherBrand]: true; - private __watcher?: Signal.subtle.Watcher; + // @internal + _partUpdateWatcher?: Signal.subtle.Watcher; + private __performUpdateWatcher?: Signal.subtle.Watcher; + private __beforeUpdateWatcher?: Signal.subtle.Watcher; + private __afterUpdateWatcher?: Signal.subtle.Watcher; + + private __flushWatcher(watcher: Signal.subtle.Watcher | undefined) { + if (watcher === undefined) { + return; + } + for (const signal of watcher.getPending()) { + signal.get(); + } + watcher.watch(); + } + + private __flushEffects() { + this.__flushWatcher(this.__beforeUpdateWatcher!); + this.__flushWatcher(this._partUpdateWatcher!); + this.__flushWatcher(this.__performUpdateWatcher!); + this.__flushWatcher(this.__afterUpdateWatcher!); + } + + // @ts-expect-error This method is called anonymously in a watcher function + private __queueEffects() { + if (this.isUpdatePending) { + return; + } + queueMicrotask(() => { + if (!this.isUpdatePending) { + this.__flushEffects(); + } + }); + } private __watch() { - if (this.__watcher !== undefined) { + if (this.__performUpdateWatcher !== undefined) { return; } // We create a fresh computed instead of just re-using the existing one @@ -74,38 +133,105 @@ export function SignalWatcher( this.__forceUpdateSignal.get(); super.performUpdate(); }); - const watcher = (this.__watcher = new Signal.subtle.Watcher(function ( - this: Signal.subtle.Watcher - ) { - // All top-level references in this function body must either be `this` - // (the watcher) or a module global to prevent this closure from keeping - // the enclosing scopes alive, which would keep the element alive. So - // The only two references are `this` and `elementForWatcher`. - const el = elementForWatcher.get(this); + const performUpdateWatcher = (this.__performUpdateWatcher = + new Signal.subtle.Watcher(function (this: Signal.subtle.Watcher) { + // All top-level references in this function body must either be `this` + // (the performUpdateWatcher) or a module global to prevent this closure from keeping + // the enclosing scopes alive, which would keep the element alive. So + // The only two references are `this` and `elementForWatcher`. + const el = elementForWatcher.get(this); + if (el === undefined) { + // The element was garbage collected, so we can stop watching. + return; + } + if (el.__forcingUpdate === false) { + el.requestUpdate(); + } + this.watch(); + })); + const watchCb = async function (this: Signal.subtle.Watcher) { + const el = elementForWatcher.get(performUpdateWatcher); if (el === undefined) { // The element was garbage collected, so we can stop watching. return; } - if (el.__forcingUpdate === false) { - el.requestUpdate(); - } this.watch(); - })); - elementForWatcher.set(watcher, this as unknown as SignalWatcherInternal); + el.__queueEffects(); + }; + const beforeUpdateWatcher = (this.__beforeUpdateWatcher = + new Signal.subtle.Watcher(watchCb)); + const partUpdateWatcher = (this._partUpdateWatcher = + new Signal.subtle.Watcher(watchCb)); + const afterUpdateWatcher = (this.__afterUpdateWatcher = + new Signal.subtle.Watcher(watchCb)); + elementForWatcher.set( + performUpdateWatcher, + this as unknown as SignalWatcherInternal + ); elementFinalizationRegistry.register(this, { - watcher, - signal: this.__performUpdateSignal, + beforeUpdateWatcher, + partUpdateWatcher, + performUpdateWatcher, + afterUpdateWatcher, }); - watcher.watch(this.__performUpdateSignal); + performUpdateWatcher.watch(this.__performUpdateSignal); + beforeUpdateWatcher.watch(...Array.from(this.#effects.before)); + afterUpdateWatcher.watch(...Array.from(this.#effects.after)); } private __unwatch() { - if (this.__watcher === undefined) { + if (this.__performUpdateWatcher === undefined) { return; } - this.__watcher.unwatch(this.__performUpdateSignal!); + this.__performUpdateWatcher?.unwatch( + ...Signal.subtle.introspectSources(this.__performUpdateWatcher!) + ); + this.__beforeUpdateWatcher?.unwatch(...Array.from(this.#effects.before)); + this.__afterUpdateWatcher?.unwatch(...Array.from(this.#effects.after)); this.__performUpdateSignal = undefined; - this.__watcher = undefined; + this.__beforeUpdateWatcher = undefined; + this._partUpdateWatcher = undefined; + this.__performUpdateWatcher = undefined; + this.__afterUpdateWatcher = undefined; + } + + #effects = { + before: new Set>(), + after: new Set>(), + }; + + effect( + fn: () => void, + options?: {beforeUpdate?: boolean; manualDispose?: boolean} + ): () => void { + this.__watch(); + const signal = new Signal.Computed(() => { + fn(); + }); + const beforeUpdate = options?.beforeUpdate ?? false; + const watcher = beforeUpdate + ? this.__beforeUpdateWatcher + : this.__afterUpdateWatcher; + watcher!.watch(signal); + const effectList = beforeUpdate + ? this.#effects.before + : this.#effects.after; + if (options?.manualDispose !== true) { + effectList.add(signal); + } + // An untracked read is safer and all that it takes to + // tell the watcher to go. + if (beforeUpdate) { + Signal.subtle.untrack(() => signal.get()); + } else { + this.updateComplete.then(() => + Signal.subtle.untrack(() => signal.get()) + ); + } + return () => { + effectList.delete(signal); + watcher!.unwatch(signal); + }; } /** @@ -138,29 +264,6 @@ export function SignalWatcher( */ private __performUpdateSignal?: Signal.Computed; - /** - * Whether or not the next update should perform a full render, or if only - * pending watches should be committed. - * - * If requestUpdate() was called only because of watch() directive updates, - * then we can just commit those directives without a full render. If - * requestUpdate() was called for any other reason, we need to perform a - * full render, and don't need to separately commit the watch() directives. - * - * This is set to `true` initially, and whenever requestUpdate() is called - * outside of a watch() directive update. It is set to `false` when - * update() is called, so that a requestUpdate() is required to do another - * full render. - */ - private __doFullRender = true; - - /** - * Set of watch directives that have been updated since the last update. - * These will be committed in update() to ensure that the latest value is - * rendered and that all updates are batched. - */ - private __pendingWatches = new Set>(); - protected override performUpdate() { if (!this.isUpdatePending) { // super.performUpdate() performs this check, so we bail early so that @@ -176,41 +279,8 @@ export function SignalWatcher( this.__forcingUpdate = true; this.__forceUpdateSignal.set(this.__forceUpdateSignal.get() + 1); this.__forcingUpdate = false; - // Always read from the signal to ensure that it's tracked - this.__performUpdateSignal!.get(); - } - - protected override update( - changedProperties: PropertyValueMap | Map - ): void { - // We need a try block because both super.update() and - // WatchDirective.commit() can throw, and we need to ensure that post- - // update cleanup happens. - try { - if (this.__doFullRender) { - // Force future updates to not perform full renders by default. - this.__doFullRender = false; - super.update(changedProperties); - } else { - // For a partial render, just commit the pending watches. - // TODO (justinfagnani): Should we access each signal in a separate - // try block? - this.__pendingWatches.forEach((d) => d.commit()); - } - } finally { - // If we didn't call super.update(), we need to set this to false - this.isUpdatePending = false; - this.__pendingWatches.clear(); - } - } - - override requestUpdate( - name?: PropertyKey | undefined, - oldValue?: unknown, - options?: PropertyDeclaration | undefined - ): void { - this.__doFullRender = true; - super.requestUpdate(name, oldValue, options); + // Flush all queued effects... + this.__flushEffects(); } override connectedCallback(): void { @@ -243,36 +313,7 @@ export function SignalWatcher( } }); } - - /** - * Enqueues an update caused by a signal change observed by a watch() - * directive. - * - * Note: the method is not part of the public API and is subject to change. - * In particular, it may be removed if the watch() directive is updated to - * work with standalone lit-html templates. - * - * @internal - */ - _updateWatchDirective(d: WatchDirective): void { - this.__pendingWatches.add(d); - // requestUpdate() will set __doFullRender to true, so remember the - // current value and restore it after calling requestUpdate(). - const shouldRender = this.__doFullRender; - this.requestUpdate(); - this.__doFullRender = shouldRender; - } - - /** - * Clears a watch() directive from the set of pending watches. - * - * Note: the method is not part of the public API and is subject to change. - * - * @internal - */ - _clearWatchDirective(d: WatchDirective): void { - this.__pendingWatches.delete(d); - } } - return SignalWatcher; + + return SignalWatcher as T & Constructor; } diff --git a/packages/labs/signals/src/lib/watch.ts b/packages/labs/signals/src/lib/watch.ts index f68d18b55a..c2c0b01fb0 100644 --- a/packages/labs/signals/src/lib/watch.ts +++ b/packages/labs/signals/src/lib/watch.ts @@ -9,6 +9,22 @@ import {AsyncDirective} from 'lit/async-directive.js'; import {Signal} from 'signal-polyfill'; import {SignalWatcher} from './signal-watcher.js'; +// Watcher for directives that are not associated with a host element. +let effectsPending = false; +const hostlessWatcher = new Signal.subtle.Watcher(async () => { + if (effectsPending) { + return; + } + effectsPending = true; + queueMicrotask(() => { + effectsPending = false; + for (const signal of hostlessWatcher.getPending()) { + signal.get(); + } + hostlessWatcher.watch(); + }); +}); + export class WatchDirective extends AsyncDirective { private __host?: SignalWatcher; @@ -25,15 +41,13 @@ export class WatchDirective extends AsyncDirective { return; } this.__computed = new Signal.Computed(() => { + this.setValue(this.__signal?.get()); return this.__signal?.get(); }); - const watcher = (this.__watcher = new Signal.subtle.Watcher(() => { - // TODO: If we're not running inside a SignalWatcher, we can commit to - // the DOM independently. - this.__host?._updateWatchDirective(this as WatchDirective); - watcher.watch(); - })); - watcher.watch(this.__computed); + this.__watcher = this.__host?._partUpdateWatcher ?? hostlessWatcher; + this.__watcher.watch(this.__computed); + // get to trigger watcher but untracked so it's not part of performUpdate + Signal.subtle.untrack(() => this.__computed?.get()); } private __unwatch() { @@ -41,14 +55,9 @@ export class WatchDirective extends AsyncDirective { this.__watcher.unwatch(this.__computed!); this.__computed = undefined; this.__watcher = undefined; - this.__host?._clearWatchDirective(this as WatchDirective); } } - commit() { - this.setValue(Signal.subtle.untrack(() => this.__computed?.get())); - } - render(signal: Signal.State | Signal.Computed): T { // This would only be called if render is called directly, like in SSR. return Signal.subtle.untrack(() => signal.get()); @@ -65,12 +74,11 @@ export class WatchDirective extends AsyncDirective { } this.__signal = signal; this.__watch(); - // We use untrack() so that the signal access is not tracked by the watcher // created by SignalWatcher. This means that an can use both SignalWatcher // and watch() and a signal update won't trigger a full element update if // it's only passed to watch() and not otherwise accessed by the element. - return Signal.subtle.untrack(() => this.__computed!.get()); + return Signal.subtle.untrack(() => this.__signal!.get()); } protected override disconnected(): void { diff --git a/packages/labs/signals/src/test/signal-watcher_test.ts b/packages/labs/signals/src/test/signal-watcher_test.ts index 69ea8da32b..5dd8a46717 100644 --- a/packages/labs/signals/src/test/signal-watcher_test.ts +++ b/packages/labs/signals/src/test/signal-watcher_test.ts @@ -229,7 +229,8 @@ suite('SignalWatcher', () => { assert.equal(readCount, 5); }); - test('type-only test where mixin on an abstract class preserves abstract type', () => { + // TODO: no longer abstract, so this test is no longer relevant. Remove? + test.skip('type-only test where mixin on an abstract class preserves abstract type', () => { if (true as boolean) { // This is a type-only test. Do not run it. return; @@ -240,9 +241,8 @@ suite('SignalWatcher', () => { // @ts-expect-error foo() needs to be implemented. class TestEl extends SignalWatcher(BaseEl) {} console.log(TestEl); // usage to satisfy eslint. - + // @ts-expect-error foo() needs to be implemented. const TestElFromAbstractSignalWatcher = SignalWatcher(BaseEl); - // @ts-expect-error cannot instantiate an abstract class. new TestElFromAbstractSignalWatcher(); // This is fine, passed in class is not abstract. @@ -250,7 +250,8 @@ suite('SignalWatcher', () => { new TestElFromConcreteClass(); }); - test('class returned from signal-watcher should be directly instantiatable if non-abstract', async () => { + // TODO: no longer abstract, so this test is no longer relevant. Remove? + test.skip('class returned from signal-watcher should be directly instantiatable if non-abstract', async () => { const count = new Signal.State(0); class TestEl extends LitElement { override render() { @@ -310,6 +311,187 @@ suite('SignalWatcher', () => { ); assert.isTrue(survivingElements.length < elementCount); }); + + test('effect notifies signal updates (after update by default)', async () => { + const count = new Signal.State(0); + const other = new Signal.State(0); + let effectCount = 0; + let effectOther = 0; + let effectCalled = 0; + class TestElement extends SignalWatcher(LitElement) { + constructor() { + super(); + this.effect(() => { + effectCount = count.get(); + effectOther = other.get(); + effectCalled++; + }); + } + override render() { + return html`

count: ${count.get()}

`; + } + } + customElements.define(generateElementName(), TestElement); + const el = new TestElement(); + container.append(el); + await el.updateComplete; + // Called initially + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 0'); + assert.equal(effectCount, 0); + assert.equal(effectOther, 0); + assert.equal(effectCalled, 1); + + // Called when signal updates that's used in render + count.set(1); + await el.updateComplete; + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 1'); + assert.equal(effectCount, 1); + assert.equal(effectOther, 0); + assert.equal(effectCalled, 2); + + // Called when any accessed signal updates + other.set(1); + await el.updateComplete; + assert.equal(effectCount, 1); + assert.equal(effectOther, 1); + assert.equal(effectCalled, 3); + + // Called when render signal and other signal updates + count.set(2); + other.set(2); + await el.updateComplete; + assert.equal(effectCount, 2); + assert.equal(effectOther, 2); + assert.equal(effectCalled, 4); + + // *Not* called when element updates + el.requestUpdate(); + await el.updateComplete; + assert.equal(effectCount, 2); + assert.equal(effectOther, 2); + assert.equal(effectCalled, 4); + }); + + test('effect notifies signal updates beforeUpdate', async () => { + const count = new Signal.State(0); + const other = new Signal.State(0); + let effectCount = 0; + let effectOther = 0; + let effectTextContent = ''; + let effectCalled = 0; + class TestElement extends SignalWatcher(LitElement) { + constructor() { + super(); + this.effect( + () => { + effectTextContent = this.hasUpdated + ? el.shadowRoot!.querySelector('p')!.textContent! + : ''; + effectCount = count.get(); + effectOther = other.get(); + effectCalled++; + }, + {beforeUpdate: true} + ); + } + override render() { + return html`

count: ${count.get()}

`; + } + } + customElements.define(generateElementName(), TestElement); + const el = new TestElement(); + container.append(el); + await el.updateComplete; + // Called initially + assert.equal(effectTextContent, ''); + assert.equal(effectCount, 0); + assert.equal(effectOther, 0); + assert.equal(effectCalled, 1); + + // Called when signal updates that's used in render + count.set(1); + await el.updateComplete; + assert.equal(effectTextContent, 'count: 0'); + assert.equal(effectCount, 1); + assert.equal(effectOther, 0); + assert.equal(effectCalled, 2); + + // Called when any accessed signal updates + other.set(1); + await el.updateComplete; + assert.equal(effectTextContent, 'count: 1'); + assert.equal(effectCount, 1); + assert.equal(effectOther, 1); + assert.equal(effectCalled, 3); + + // Called when render signal and other signal updates + count.set(2); + other.set(2); + await el.updateComplete; + assert.equal(effectTextContent, 'count: 1'); + assert.equal(effectCount, 2); + assert.equal(effectOther, 2); + assert.equal(effectCalled, 4); + + // *Not* called when element updates + el.requestUpdate(); + assert.equal(effectTextContent, 'count: 1'); + await el.updateComplete; + assert.equal(effectCount, 2); + assert.equal(effectOther, 2); + assert.equal(effectCalled, 4); + }); + + test('effects disposed when disconnected', async () => { + const count = new Signal.State(0); + class TestElement extends SignalWatcher(LitElement) { + override render() { + return html`

count: ${count.get()}

`; + } + } + customElements.define(generateElementName(), TestElement); + const el = new TestElement(); + container.append(el); + await el.updateComplete; + let effectCount = 0; + el.effect(() => { + effectCount = count.get(); + }); + await el.updateComplete; + assert.equal(effectCount, 0); + el.remove(); + await el.updateComplete; + count.set(1); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(effectCount, 0); + }); + + test('can manually dispose of effects', async () => { + const count = new Signal.State(0); + const other = new Signal.State(0); + class TestElement extends SignalWatcher(LitElement) { + override render() { + return html`

count: ${count.get()}

`; + } + } + customElements.define(generateElementName(), TestElement); + const el = new TestElement(); + container.append(el); + await el.updateComplete; + let effectOther = undefined; + const disposeEffect = el.effect(() => { + effectOther = other.get(); + }); + await el.updateComplete; + assert.equal(effectOther, 0); + other.set(1); + await el.updateComplete; + assert.equal(effectOther, 1); + disposeEffect(); + other.set(2); + await el.updateComplete; + assert.equal(effectOther, 1); + }); }); declare global { diff --git a/packages/labs/signals/src/test/watch_test.ts b/packages/labs/signals/src/test/watch_test.ts index 7485442561..ea26acbbc8 100644 --- a/packages/labs/signals/src/test/watch_test.ts +++ b/packages/labs/signals/src/test/watch_test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {LitElement, html} from 'lit'; +import {LitElement, html, render} from 'lit'; import {property} from 'lit/decorators.js'; import {cache} from 'lit/directives/cache.js'; import {assert} from '@esm-bundle/chai'; @@ -50,15 +50,172 @@ suite('watch directive', () => { // The DOM updates because signal update count.set(1); - assert.isTrue(el.isUpdatePending); + // watch does *not* trigger update + assert.isFalse(el.isUpdatePending); + await el.updateComplete; + assert.equal( + el.shadowRoot?.querySelector('p')?.textContent, + 'count: 1', + 'B' + ); + // The updated DOM is not because of an element render + assert.equal(renderCount, 1); + }); + + test('watches a signal with Lit render', async () => { + const count = signal(0); + const template = () => { + return html`

count: ${watch(count)}

+

count: ${watch(count)}

+

count: ${watch(count)}

`; + }; + const el = document.createElement('div'); + container.append(el); + render(template(), el); + el.querySelectorAll('p').forEach((p) => { + assert.equal(p.textContent, 'count: 0', 'A'); + }); + // The DOM updates because signal update + count.set(1); + await new Promise(requestAnimationFrame); + el.querySelectorAll('p').forEach((p) => { + assert.equal(p.textContent, 'count: 1', 'B'); + }); + count.set(2); + await new Promise(requestAnimationFrame); + el.querySelectorAll('p').forEach((p) => { + assert.equal(p.textContent, 'count: 2', 'C'); + }); + }); + + test('can mix signals in render with watch', async () => { + let renderCount = 0; + const count = signal(0); + const renderSignal = signal(0); + class TestElement extends SignalWatcher(LitElement) { + override render() { + renderCount++; + return html`

count: ${watch(count)}

+
renderSignal: ${renderSignal.get()}
`; + } + } + customElements.define(generateElementName(), TestElement); + const el = new TestElement(); + container.append(el); + + // The first DOM update is because of an element render + await el.updateComplete; + assert.equal( + el.shadowRoot?.querySelector('p')?.textContent, + 'count: 0', + 'A' + ); + assert.equal( + el.shadowRoot?.querySelector('div')?.textContent, + 'renderSignal: 0', + 'A' + ); + assert.equal(renderCount, 1); + assert.isFalse(el.isUpdatePending); + + // The DOM updates because signal update + count.set(1); + assert.isFalse(el.isUpdatePending); await el.updateComplete; assert.equal( el.shadowRoot?.querySelector('p')?.textContent, 'count: 1', 'B' ); + assert.equal( + el.shadowRoot?.querySelector('div')?.textContent, + 'renderSignal: 0', + 'B' + ); // The updated DOM is not because of an element render assert.equal(renderCount, 1); + renderSignal.set(1); + assert.isTrue(el.isUpdatePending); + await el.updateComplete; + assert.equal( + el.shadowRoot?.querySelector('p')?.textContent, + 'count: 1', + 'C' + ); + assert.equal( + el.shadowRoot?.querySelector('div')?.textContent, + 'renderSignal: 1', + 'C' + ); + // The updated DOM is because of an element render + assert.equal(renderCount, 2); + // Update watch signal again, after a render signal update + count.set(2); + assert.isFalse(el.isUpdatePending); + await el.updateComplete; + assert.equal( + el.shadowRoot?.querySelector('p')?.textContent, + 'count: 2', + 'B' + ); + assert.equal( + el.shadowRoot?.querySelector('div')?.textContent, + 'renderSignal: 1', + 'B' + ); + }); + + test('effect + watch', async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + let renderCount = 0; + const count = signal(0); + class TestElement extends SignalWatcher(LitElement) { + override render() { + renderCount++; + return html`

count: ${watch(count)}

`; + } + } + customElements.define(generateElementName(), TestElement); + const el = new TestElement(); + container.append(el); + + // The first DOM update is because of an element render + await el.updateComplete; + let effectBeforeValue = -1; + let effectBeforeDom: string | null | undefined = ''; + const disposeEffectBefore = el.effect( + () => { + effectBeforeValue = count.get(); + effectBeforeDom = el.shadowRoot?.querySelector('p')?.textContent; + }, + {beforeUpdate: true} + ); + let effectValue = -1; + let effectDom: string | null | undefined = ''; + const disposeEffect = el.effect(() => { + effectValue = count.get(); + effectDom = el.shadowRoot?.querySelector('p')?.textContent; + }); + await el.updateComplete; + assert.equal(effectBeforeValue, 0); + assert.equal(effectBeforeDom, 'count: 0'); + assert.equal(effectValue, 0); + assert.equal(effectDom, 'count: 0'); + + count.set(1); + await el.updateComplete; + assert.equal(effectBeforeValue, 1); + assert.equal(effectBeforeDom, 'count: 0'); + assert.equal(effectValue, 1); + assert.equal(effectDom, 'count: 1'); + disposeEffect(); + disposeEffectBefore(); + + count.set(2); + assert.equal(effectBeforeValue, 1); + assert.equal(effectBeforeDom, 'count: 0'); + assert.equal(effectValue, 1); + assert.equal(effectDom, 'count: 1'); }); test('unsubscribes to a signal on element disconnect', async () => { From 00de5b2ea596a771e659d77f1b32c0a943156f16 Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Thu, 27 Feb 2025 09:34:15 -0800 Subject: [PATCH 02/11] adds changeset --- .changeset/tame-seas-train.md | 5 +++++ package-lock.json | 38 +++++++++++++++++------------------ 2 files changed, 24 insertions(+), 19 deletions(-) create mode 100644 .changeset/tame-seas-train.md diff --git a/.changeset/tame-seas-train.md b/.changeset/tame-seas-train.md new file mode 100644 index 0000000000..a235f06f36 --- /dev/null +++ b/.changeset/tame-seas-train.md @@ -0,0 +1,5 @@ +--- +'@lit-labs/signals': minor +--- + +watch no longer triggers update; added SignalWatcher.effect diff --git a/package-lock.json b/package-lock.json index 82d73abfb5..658b62c379 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30065,7 +30065,7 @@ }, "packages/context": { "name": "@lit/context", - "version": "1.1.3", + "version": "1.1.4", "license": "BSD-3-Clause", "dependencies": { "@lit/reactive-element": "^1.6.2 || ^2.0.0" @@ -30111,7 +30111,7 @@ }, "packages/labs/analyzer": { "name": "@lit-labs/analyzer", - "version": "0.13.1", + "version": "0.13.2", "license": "BSD-3-Clause", "dependencies": { "package-json-type": "^1.0.3", @@ -30256,7 +30256,7 @@ }, "packages/labs/compiler": { "name": "@lit-labs/compiler", - "version": "1.1.0", + "version": "1.1.1", "license": "BSD-3-Clause", "dependencies": { "@lit-labs/analyzer": "^0.13.0", @@ -30319,7 +30319,7 @@ }, "packages/labs/eleventy-plugin-lit": { "name": "@lit-labs/eleventy-plugin-lit", - "version": "1.0.4", + "version": "1.0.5", "license": "BSD-3-Clause", "dependencies": { "@lit-labs/ssr": "^3.3.0", @@ -30336,7 +30336,7 @@ }, "packages/labs/eslint-plugin": { "name": "eslint-plugin-lit", - "version": "0.0.2", + "version": "0.0.3", "license": "BSD-3-Clause", "dependencies": { "@lit-labs/analyzer": "^0.13.0", @@ -30459,7 +30459,7 @@ }, "packages/labs/motion": { "name": "@lit-labs/motion", - "version": "1.0.7", + "version": "1.0.8", "license": "BSD-3-Clause", "dependencies": { "lit": "^3.1.2" @@ -30471,7 +30471,7 @@ }, "packages/labs/nextjs": { "name": "@lit-labs/nextjs", - "version": "0.2.1", + "version": "0.2.2", "license": "BSD-3-Clause", "dependencies": { "@lit-labs/ssr-react": "^0.3.0", @@ -30489,7 +30489,7 @@ }, "packages/labs/observers": { "name": "@lit-labs/observers", - "version": "2.0.4", + "version": "2.0.5", "license": "BSD-3-Clause", "dependencies": { "@lit/reactive-element": "^1.0.0 || ^2.0.0", @@ -30502,7 +30502,7 @@ }, "packages/labs/preact-signals": { "name": "@lit-labs/preact-signals", - "version": "1.0.2", + "version": "1.0.3", "license": "BSD-3-Clause", "dependencies": { "@preact/signals-core": "^1.3.0", @@ -31014,7 +31014,7 @@ }, "packages/labs/router": { "name": "@lit-labs/router", - "version": "0.1.3", + "version": "0.1.4", "license": "BSD-3-Clause", "dependencies": { "lit": "^2.0.0 || ^3.0.0" @@ -31033,7 +31033,7 @@ }, "packages/labs/scoped-registry-mixin": { "name": "@lit-labs/scoped-registry-mixin", - "version": "1.0.3", + "version": "1.0.4", "license": "BSD-3-Clause", "dependencies": { "@lit/reactive-element": "^1.0.0 || ^2.0.0", @@ -31047,7 +31047,7 @@ }, "packages/labs/signals": { "name": "@lit-labs/signals", - "version": "0.1.1", + "version": "0.1.2", "license": "BSD-3-Clause", "dependencies": { "lit": "^2.0.0 || ^3.0.0", @@ -31065,7 +31065,7 @@ }, "packages/labs/ssr": { "name": "@lit-labs/ssr", - "version": "3.3.0", + "version": "3.3.1", "license": "BSD-3-Clause", "dependencies": { "@lit-labs/ssr-client": "^1.1.7", @@ -31124,7 +31124,7 @@ }, "packages/labs/ssr-react": { "name": "@lit-labs/ssr-react", - "version": "0.3.1", + "version": "0.3.2", "license": "BSD-3-Clause", "dependencies": { "@lit-labs/ssr": "^3.3.0", @@ -31210,7 +31210,7 @@ }, "packages/labs/testing": { "name": "@lit-labs/testing", - "version": "0.2.6", + "version": "0.2.7", "license": "BSD-3-Clause", "dependencies": { "@lit-labs/ssr": "^3.3.0", @@ -31225,7 +31225,7 @@ }, "packages/labs/tsserver-plugin": { "name": "@lit-labs/tsserver-plugin", - "version": "0.0.0", + "version": "0.0.1", "license": "BSD-3-Clause", "devDependencies": { "typescript": "~5.5.0" @@ -31233,7 +31233,7 @@ }, "packages/labs/virtualizer": { "name": "@lit-labs/virtualizer", - "version": "2.0.15", + "version": "2.1.0", "license": "BSD-3-Clause", "dependencies": { "lit": "^3.2.0", @@ -31247,7 +31247,7 @@ }, "packages/labs/vue-utils": { "name": "@lit-labs/vue-utils", - "version": "0.1.1", + "version": "0.1.2", "license": "BSD-3-Clause", "dependencies": { "vue": "^3.2.25" @@ -31969,7 +31969,7 @@ }, "packages/ts-transformers": { "name": "@lit/ts-transformers", - "version": "2.0.1", + "version": "2.0.2", "license": "BSD-3-Clause", "dependencies": { "ts-clone-node": "^3.0.0" From 195d5f41226f6d0b3c07a45124890376c5980e76 Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Sun, 2 Mar 2025 05:40:53 -0800 Subject: [PATCH 03/11] update readme --- packages/labs/signals/README.md | 44 +++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/labs/signals/README.md b/packages/labs/signals/README.md index 06a63c94a5..ea663839b5 100644 --- a/packages/labs/signals/README.md +++ b/packages/labs/signals/README.md @@ -196,24 +196,12 @@ export class SignalExample extends SignalWatcher(LitElement) { } ``` -`watch()` updates are batched and run in coordination with the reactive update -lifecycle. When a watched signal changes, it is added to a batch and a reactive -update is requested. Other changes, to reactive properties or signals accessed -outside of `watch()`, are trigger reactive updates as usual. - -> [!NOTE] -> -> -> -> During a reactive update, if there are only updates from `watch()` directives, -> then those updates are commited directly _without_ a full template render. If -> any other changes triggered the reactive update, then the whole template is -> re-rendered, along with the latest signal values. - -This approach preserves both DOM coherence and targeted updates, and coalesces -updates when both signals and reactive properties change. - -`watch()` must be used in conjunction with the `SignalWatcher` mixin. +`watch()` updates do not trigger the Lit reactive update cycle. However, they +are batched and run in coordination with reactive updates. When a watched +signal changes, if a reactive update is pending, the watched signal update +renders with the update; otherwise, it renders in a batch with any other +watched signals. Other changes, to reactive properties or signals accessed +outside of `watch()`, trigger reactive updates as usual. You can mix and match targeted updates with `watch()` directive and auto-tracking with `SignalWatcher`. When you pass a signal directly to `watch()` @@ -221,6 +209,26 @@ it is not accessed in a callback watched by `SignalWatcher`, so an update to that signal will only cause a targeted DOM update and not an full template render. +> [!NOTE] +> +> +> +> The value passed to `watch` must be a signal. If it isn't, it can be +> converted to one using `computed`. For example +> `${watch(computed(() => list.get().map(item => item))}`. + +### this.effect + +`SignalWatcher` also exposes an `effect(callback)` method that allows targeted +reactions to signal changes independent of but coordinated with the reactive +update lifecycle. This provides an easy mechanism to react generally to signals +used only with `watch` that do not trigger a reactive update. By default, +the `effect` is run after any DOM based updates are rendered, including +the element's reactive update cycle, if it is pending when the effect would +trigger. Adding an options argument with `beforeUpdate: true` allows effects +to run before any DOM is updated. For example, +`this.effect(() => console.log(this.aSignal.get())), {beforeUpdate: true});` + ### html tag and withWatch() This package also exports an `html` template tag that can be used in place of From e0e617233fe77c7d966d593ad8c8355e57b9a31c Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Tue, 11 Mar 2025 08:13:27 -0700 Subject: [PATCH 04/11] address feedback: simplify to use a single watcher --- .../labs/signals/src/lib/signal-watcher.ts | 209 +++++++----------- packages/labs/signals/src/lib/watch.ts | 2 +- 2 files changed, 86 insertions(+), 125 deletions(-) diff --git a/packages/labs/signals/src/lib/signal-watcher.ts b/packages/labs/signals/src/lib/signal-watcher.ts index 813359f1df..86a8ee6411 100644 --- a/packages/labs/signals/src/lib/signal-watcher.ts +++ b/packages/labs/signals/src/lib/signal-watcher.ts @@ -8,14 +8,16 @@ import type {ReactiveElement} from 'lit'; import {Signal} from 'signal-polyfill'; export interface SignalWatcher extends ReactiveElement { - _partUpdateWatcher?: Signal.subtle.Watcher; + _watcher?: Signal.subtle.Watcher; +} + +export interface EffectOptions { + beforeUpdate?: boolean; + manualDispose?: boolean; } interface SignalWatcherApi { - effect( - fn: () => void, - options?: {beforeUpdate?: boolean; manualDispose?: boolean} - ): () => void; + effect(fn: () => void, options?: EffectOptions): () => void; } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -24,10 +26,8 @@ type Constructor = new (...args: any[]) => T; interface SignalWatcherInterface extends SignalWatcher {} interface SignalWatcherInternal extends SignalWatcher { __forcingUpdate: boolean; - __beforeUpdateWatcher?: Signal.subtle.Watcher; - __performUpdateWatcher?: Signal.subtle.Watcher; - __afterUpdateWatcher?: Signal.subtle.Watcher; - __flushEffects: () => void; + __performUpdateSignal?: Signal.Computed; + requestUpdate(): void; __queueEffects: () => void; } @@ -38,33 +38,10 @@ const signalWatcherBrand: unique symbol = Symbol('SignalWatcherBrand'); // by the signals it watches. To avoid this, we break the cycle by using a // WeakMap to store the watcher for each element, and a FinalizationRegistry to // clean up the watcher when the element is garbage collected. - -const elementFinalizationRegistry = new FinalizationRegistry<{ - beforeUpdateWatcher: Signal.subtle.Watcher; - performUpdateWatcher: Signal.subtle.Watcher; - partUpdateWatcher: Signal.subtle.Watcher; - afterUpdateWatcher: Signal.subtle.Watcher; -}>( - ({ - beforeUpdateWatcher, - performUpdateWatcher, - partUpdateWatcher, - afterUpdateWatcher, - }) => { - beforeUpdateWatcher.unwatch( - ...Signal.subtle.introspectSources(beforeUpdateWatcher) - ); - performUpdateWatcher.unwatch( - ...Signal.subtle.introspectSources(performUpdateWatcher) - ); - partUpdateWatcher.unwatch( - ...Signal.subtle.introspectSources(partUpdateWatcher) - ); - afterUpdateWatcher.unwatch( - ...Signal.subtle.introspectSources(afterUpdateWatcher) - ); - } -); +const elementFinalizationRegistry = + new FinalizationRegistry((watcher) => { + watcher.unwatch(...Signal.subtle.introspectSources(watcher)); + }); const elementForWatcher = new WeakMap< Signal.subtle.Watcher, @@ -88,27 +65,33 @@ export function SignalWatcher>(Base: T) { class SignalWatcher extends Base implements SignalWatcherInterface { static [signalWatcherBrand]: true; - // @internal - _partUpdateWatcher?: Signal.subtle.Watcher; - private __performUpdateWatcher?: Signal.subtle.Watcher; - private __beforeUpdateWatcher?: Signal.subtle.Watcher; - private __afterUpdateWatcher?: Signal.subtle.Watcher; - - private __flushWatcher(watcher: Signal.subtle.Watcher | undefined) { - if (watcher === undefined) { - return; - } - for (const signal of watcher.getPending()) { - signal.get(); - } - watcher.watch(); - } + // @internal used in watch directive + _watcher?: Signal.subtle.Watcher; + /** + * Flushes effects in required order: + * 1. Before update effects + * 2. Perform update + * 3. Pending watches + * 4. After update effects + * */ private __flushEffects() { - this.__flushWatcher(this.__beforeUpdateWatcher!); - this.__flushWatcher(this._partUpdateWatcher!); - this.__flushWatcher(this.__performUpdateWatcher!); - this.__flushWatcher(this.__afterUpdateWatcher!); + const beforeEffects = [] as Signal.Computed[]; + const afterEffects = [] as Signal.Computed[]; + this.__effects.forEach((options, signal) => { + const list = options?.beforeUpdate ? beforeEffects : afterEffects; + list.push(signal); + }); + const pendingWatches = this._watcher + ?.getPending() + .filter( + (signal) => + signal !== this.__performUpdateSignal && !this.__effects.has(signal) + ); + beforeEffects.forEach((signal) => signal.get()); + this.__performUpdateSignal?.get(); + pendingWatches!.forEach((signal) => signal.get()); + afterEffects.forEach((signal) => signal.get()); } // @ts-expect-error This method is called anonymously in a watcher function @@ -124,7 +107,7 @@ export function SignalWatcher>(Base: T) { } private __watch() { - if (this.__performUpdateWatcher !== undefined) { + if (this._watcher !== undefined) { return; } // We create a fresh computed instead of just re-using the existing one @@ -133,92 +116,70 @@ export function SignalWatcher>(Base: T) { this.__forceUpdateSignal.get(); super.performUpdate(); }); - const performUpdateWatcher = (this.__performUpdateWatcher = - new Signal.subtle.Watcher(function (this: Signal.subtle.Watcher) { - // All top-level references in this function body must either be `this` - // (the performUpdateWatcher) or a module global to prevent this closure from keeping - // the enclosing scopes alive, which would keep the element alive. So - // The only two references are `this` and `elementForWatcher`. - const el = elementForWatcher.get(this); - if (el === undefined) { - // The element was garbage collected, so we can stop watching. - return; - } - if (el.__forcingUpdate === false) { - el.requestUpdate(); - } - this.watch(); - })); - const watchCb = async function (this: Signal.subtle.Watcher) { - const el = elementForWatcher.get(performUpdateWatcher); + const watcher = (this._watcher = new Signal.subtle.Watcher(function ( + this: Signal.subtle.Watcher + ) { + // All top-level references in this function body must either be `this` + // (the `watcher`) or a module global to prevent this closure from keeping + // the enclosing scopes alive, which would keep the element alive. So + // The only two references are `this` and `elementForWatcher`. + const el = elementForWatcher.get(this); if (el === undefined) { // The element was garbage collected, so we can stop watching. return; } + if (el.__forcingUpdate === false) { + const needsUpdate = new Set(this.getPending()).has( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (el as any).__performUpdateSignal + ); + if (needsUpdate) { + el.requestUpdate(); + } else { + el.__queueEffects(); + } + } this.watch(); - el.__queueEffects(); - }; - const beforeUpdateWatcher = (this.__beforeUpdateWatcher = - new Signal.subtle.Watcher(watchCb)); - const partUpdateWatcher = (this._partUpdateWatcher = - new Signal.subtle.Watcher(watchCb)); - const afterUpdateWatcher = (this.__afterUpdateWatcher = - new Signal.subtle.Watcher(watchCb)); - elementForWatcher.set( - performUpdateWatcher, - this as unknown as SignalWatcherInternal - ); - elementFinalizationRegistry.register(this, { - beforeUpdateWatcher, - partUpdateWatcher, - performUpdateWatcher, - afterUpdateWatcher, - }); - performUpdateWatcher.watch(this.__performUpdateSignal); - beforeUpdateWatcher.watch(...Array.from(this.#effects.before)); - afterUpdateWatcher.watch(...Array.from(this.#effects.after)); + })); + elementForWatcher.set(watcher, this as unknown as SignalWatcherInternal); + elementFinalizationRegistry.register(this, watcher); + watcher.watch(this.__performUpdateSignal); + watcher.watch(...Array.from(this.__effects).map(([signal]) => signal)); } private __unwatch() { - if (this.__performUpdateWatcher === undefined) { + if (this._watcher === undefined) { return; } - this.__performUpdateWatcher?.unwatch( - ...Signal.subtle.introspectSources(this.__performUpdateWatcher!) + // We unwatch all signals that are not manually disposed, so that we don't + // keep the element alive by holding references to it. + this._watcher.unwatch( + ...Signal.subtle + .introspectSources(this._watcher!) + .filter( + (signal) => + this.__effects.get(signal as Signal.Computed) + ?.manualDispose !== true + ) ); - this.__beforeUpdateWatcher?.unwatch(...Array.from(this.#effects.before)); - this.__afterUpdateWatcher?.unwatch(...Array.from(this.#effects.after)); this.__performUpdateSignal = undefined; - this.__beforeUpdateWatcher = undefined; - this._partUpdateWatcher = undefined; - this.__performUpdateWatcher = undefined; - this.__afterUpdateWatcher = undefined; + this._watcher = undefined; } - #effects = { - before: new Set>(), - after: new Set>(), - }; + // list signals managing effects, stored with effect options. + private __effects = new Map< + Signal.Computed, + EffectOptions | undefined + >(); - effect( - fn: () => void, - options?: {beforeUpdate?: boolean; manualDispose?: boolean} - ): () => void { + effect(fn: () => void, options?: EffectOptions): () => void { this.__watch(); const signal = new Signal.Computed(() => { fn(); }); + this._watcher!.watch(signal); + this.__effects.set(signal, options); const beforeUpdate = options?.beforeUpdate ?? false; - const watcher = beforeUpdate - ? this.__beforeUpdateWatcher - : this.__afterUpdateWatcher; - watcher!.watch(signal); - const effectList = beforeUpdate - ? this.#effects.before - : this.#effects.after; - if (options?.manualDispose !== true) { - effectList.add(signal); - } // An untracked read is safer and all that it takes to // tell the watcher to go. if (beforeUpdate) { @@ -229,8 +190,8 @@ export function SignalWatcher>(Base: T) { ); } return () => { - effectList.delete(signal); - watcher!.unwatch(signal); + this.__effects.delete(signal); + this._watcher!.unwatch(signal); }; } diff --git a/packages/labs/signals/src/lib/watch.ts b/packages/labs/signals/src/lib/watch.ts index c2c0b01fb0..730e11d977 100644 --- a/packages/labs/signals/src/lib/watch.ts +++ b/packages/labs/signals/src/lib/watch.ts @@ -44,7 +44,7 @@ export class WatchDirective extends AsyncDirective { this.setValue(this.__signal?.get()); return this.__signal?.get(); }); - this.__watcher = this.__host?._partUpdateWatcher ?? hostlessWatcher; + this.__watcher = this.__host?._watcher ?? hostlessWatcher; this.__watcher.watch(this.__computed); // get to trigger watcher but untracked so it's not part of performUpdate Signal.subtle.untrack(() => this.__computed?.get()); From a48ccd0a48290031e4867aca5e23120528d4f7aa Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Tue, 11 Mar 2025 09:48:30 -0700 Subject: [PATCH 05/11] remove any cast --- packages/labs/signals/src/lib/signal-watcher.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/labs/signals/src/lib/signal-watcher.ts b/packages/labs/signals/src/lib/signal-watcher.ts index 86a8ee6411..dae64685a7 100644 --- a/packages/labs/signals/src/lib/signal-watcher.ts +++ b/packages/labs/signals/src/lib/signal-watcher.ts @@ -130,8 +130,7 @@ export function SignalWatcher>(Base: T) { } if (el.__forcingUpdate === false) { const needsUpdate = new Set(this.getPending()).has( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (el as any).__performUpdateSignal + el.__performUpdateSignal as Signal.Computed ); if (needsUpdate) { el.requestUpdate(); From 9d4650833071b969c71b481e9397102f5717f506 Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Tue, 11 Mar 2025 16:21:05 -0700 Subject: [PATCH 06/11] Address feedback: `effect` is a standalone API --- .changeset/tame-seas-train.md | 2 +- .../labs/signals/src/lib/signal-watcher.ts | 55 +++++++++++++++++- .../signals/src/test/signal-watcher_test.ts | 56 ++++++++++++++----- packages/labs/signals/src/test/watch_test.ts | 17 +++--- 4 files changed, 105 insertions(+), 25 deletions(-) diff --git a/.changeset/tame-seas-train.md b/.changeset/tame-seas-train.md index a235f06f36..0a0c1085ad 100644 --- a/.changeset/tame-seas-train.md +++ b/.changeset/tame-seas-train.md @@ -2,4 +2,4 @@ '@lit-labs/signals': minor --- -watch no longer triggers update; added SignalWatcher.effect +`watch` no longer triggers update; adds `effect(callback, options)` diff --git a/packages/labs/signals/src/lib/signal-watcher.ts b/packages/labs/signals/src/lib/signal-watcher.ts index dae64685a7..09a268a30f 100644 --- a/packages/labs/signals/src/lib/signal-watcher.ts +++ b/packages/labs/signals/src/lib/signal-watcher.ts @@ -11,13 +11,61 @@ export interface SignalWatcher extends ReactiveElement { _watcher?: Signal.subtle.Watcher; } -export interface EffectOptions { +interface EffectOptions { beforeUpdate?: boolean; manualDispose?: boolean; } +export interface ElementEffectOptions extends EffectOptions { + element?: SignalWatcher & SignalWatcherApi; +} + +let effectsPending = false; +const effectWatcher = new Signal.subtle.Watcher(() => { + if (effectsPending) { + return; + } + effectsPending = true; + queueMicrotask(() => { + effectsPending = false; + for (const signal of effectWatcher.getPending()) { + signal.get(); + } + effectWatcher.watch(); + }); +}); + +/** + * Executes the provided callback function when any of the signals it accesses + * change. If an options object is provided, the `element` property can be used + * to specify the element to associate the effect with. The `beforeUpdate` + * property can be used to specify that the effect should run before the element + * updates. The `manualDispose` property can be used to specify that the effect + * should not be automatically disposed when the element is disconnected. + * + * @param callback + * @param options {element, beforeUpdate, manualDispose} + * @returns + */ +export const effect = ( + callback: () => void, + options?: ElementEffectOptions +) => { + const {element} = options ?? {}; + if (element === undefined) { + const computed = new Signal.Computed(callback); + effectWatcher.watch(computed); + Signal.subtle.untrack(() => computed.get()); + return () => { + effectWatcher.unwatch(computed); + }; + } else { + return element._effect(callback, options); + } +}; + interface SignalWatcherApi { - effect(fn: () => void, options?: EffectOptions): () => void; + _effect(fn: () => void, options?: EffectOptions): () => void; } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -171,7 +219,8 @@ export function SignalWatcher>(Base: T) { EffectOptions | undefined >(); - effect(fn: () => void, options?: EffectOptions): () => void { + // @internal exposed via `effect` + _effect(fn: () => void, options?: EffectOptions): () => void { this.__watch(); const signal = new Signal.Computed(() => { fn(); diff --git a/packages/labs/signals/src/test/signal-watcher_test.ts b/packages/labs/signals/src/test/signal-watcher_test.ts index 5dd8a46717..f73bfeb0c7 100644 --- a/packages/labs/signals/src/test/signal-watcher_test.ts +++ b/packages/labs/signals/src/test/signal-watcher_test.ts @@ -7,7 +7,7 @@ import {LitElement, html} from 'lit'; import {assert} from '@esm-bundle/chai'; -import {SignalWatcher, Signal} from '../index.js'; +import {SignalWatcher, Signal, effect} from '../index.js'; import {customElement, property} from 'lit/decorators.js'; let elementNameId = 0; @@ -321,11 +321,14 @@ suite('SignalWatcher', () => { class TestElement extends SignalWatcher(LitElement) { constructor() { super(); - this.effect(() => { - effectCount = count.get(); - effectOther = other.get(); - effectCalled++; - }); + effect( + () => { + effectCount = count.get(); + effectOther = other.get(); + effectCalled++; + }, + {element: this} + ); } override render() { return html`

count: ${count.get()}

`; @@ -382,7 +385,7 @@ suite('SignalWatcher', () => { class TestElement extends SignalWatcher(LitElement) { constructor() { super(); - this.effect( + effect( () => { effectTextContent = this.hasUpdated ? el.shadowRoot!.querySelector('p')!.textContent! @@ -391,7 +394,7 @@ suite('SignalWatcher', () => { effectOther = other.get(); effectCalled++; }, - {beforeUpdate: true} + {element: this, beforeUpdate: true} ); } override render() { @@ -454,9 +457,12 @@ suite('SignalWatcher', () => { container.append(el); await el.updateComplete; let effectCount = 0; - el.effect(() => { - effectCount = count.get(); - }); + effect( + () => { + effectCount = count.get(); + }, + {element: el} + ); await el.updateComplete; assert.equal(effectCount, 0); el.remove(); @@ -479,9 +485,12 @@ suite('SignalWatcher', () => { container.append(el); await el.updateComplete; let effectOther = undefined; - const disposeEffect = el.effect(() => { - effectOther = other.get(); - }); + const disposeEffect = effect( + () => { + effectOther = other.get(); + }, + {element: el} + ); await el.updateComplete; assert.equal(effectOther, 0); other.set(1); @@ -492,6 +501,25 @@ suite('SignalWatcher', () => { await el.updateComplete; assert.equal(effectOther, 1); }); + + test('standalone effects', async () => { + const count = new Signal.State(0); + const frame = () => new Promise(requestAnimationFrame); + let effectCount; + const dispose = effect(() => { + effectCount = count.get(); + }); + await frame(); + // Called initially + assert.equal(effectCount, 0); + count.set(1); + await frame(); + assert.equal(effectCount, 1); + dispose(); + count.set(2); + await frame(); + assert.equal(effectCount, 1); + }); }); declare global { diff --git a/packages/labs/signals/src/test/watch_test.ts b/packages/labs/signals/src/test/watch_test.ts index ea26acbbc8..9c0695b611 100644 --- a/packages/labs/signals/src/test/watch_test.ts +++ b/packages/labs/signals/src/test/watch_test.ts @@ -8,7 +8,7 @@ import {LitElement, html, render} from 'lit'; import {property} from 'lit/decorators.js'; import {cache} from 'lit/directives/cache.js'; import {assert} from '@esm-bundle/chai'; -import {watch, signal, computed, SignalWatcher} from '../index.js'; +import {watch, signal, computed, SignalWatcher, effect} from '../index.js'; let elementNameId = 0; const generateElementName = () => `test-${elementNameId++}`; @@ -183,19 +183,22 @@ suite('watch directive', () => { await el.updateComplete; let effectBeforeValue = -1; let effectBeforeDom: string | null | undefined = ''; - const disposeEffectBefore = el.effect( + const disposeEffectBefore = effect( () => { effectBeforeValue = count.get(); effectBeforeDom = el.shadowRoot?.querySelector('p')?.textContent; }, - {beforeUpdate: true} + {element: el, beforeUpdate: true} ); let effectValue = -1; let effectDom: string | null | undefined = ''; - const disposeEffect = el.effect(() => { - effectValue = count.get(); - effectDom = el.shadowRoot?.querySelector('p')?.textContent; - }); + const disposeEffect = effect( + () => { + effectValue = count.get(); + effectDom = el.shadowRoot?.querySelector('p')?.textContent; + }, + {element: el} + ); await el.updateComplete; assert.equal(effectBeforeValue, 0); assert.equal(effectBeforeDom, 'count: 0'); From 5c473d39e3b6564f6bd5bf4903687844c6ea024a Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Thu, 17 Jul 2025 14:03:43 -0700 Subject: [PATCH 07/11] address feedback --- packages/labs/signals/src/lib/signal-watcher.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/labs/signals/src/lib/signal-watcher.ts b/packages/labs/signals/src/lib/signal-watcher.ts index 09a268a30f..e605d7f1ce 100644 --- a/packages/labs/signals/src/lib/signal-watcher.ts +++ b/packages/labs/signals/src/lib/signal-watcher.ts @@ -40,12 +40,12 @@ const effectWatcher = new Signal.subtle.Watcher(() => { * change. If an options object is provided, the `element` property can be used * to specify the element to associate the effect with. The `beforeUpdate` * property can be used to specify that the effect should run before the element - * updates. The `manualDispose` property can be used to specify that the effect - * should not be automatically disposed when the element is disconnected. + * updates. An effect is automatically disposed when an element specified in + * the options is disconnected. The `manualDispose` property can be set to + * `true` to prevent the effect from being automatically disposed. * * @param callback * @param options {element, beforeUpdate, manualDispose} - * @returns */ export const effect = ( callback: () => void, @@ -65,6 +65,7 @@ export const effect = ( }; interface SignalWatcherApi { + /** @internal */ _effect(fn: () => void, options?: EffectOptions): () => void; } @@ -219,7 +220,7 @@ export function SignalWatcher>(Base: T) { EffectOptions | undefined >(); - // @internal exposed via `effect` + /** @internal exposed via `effect` */ _effect(fn: () => void, options?: EffectOptions): () => void { this.__watch(); const signal = new Signal.Computed(() => { From dce858175883bce5a61dce29468b2ef68b1a72af Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Thu, 17 Jul 2025 15:48:51 -0700 Subject: [PATCH 08/11] address feedback --- .../labs/signals/src/lib/signal-watcher.ts | 85 +++++++-------- .../signals/src/test/signal-watcher_test.ts | 102 +++++++++++------- packages/labs/signals/src/test/watch_test.ts | 19 ++-- 3 files changed, 115 insertions(+), 91 deletions(-) diff --git a/packages/labs/signals/src/lib/signal-watcher.ts b/packages/labs/signals/src/lib/signal-watcher.ts index e605d7f1ce..5e327229b7 100644 --- a/packages/labs/signals/src/lib/signal-watcher.ts +++ b/packages/labs/signals/src/lib/signal-watcher.ts @@ -12,7 +12,17 @@ export interface SignalWatcher extends ReactiveElement { } interface EffectOptions { + /** + * By default effects run after the element has updated. If `beforeUpdate` + * is set to `true`, the effect will run before the element updates. + */ beforeUpdate?: boolean; + /** + * By default, effects are automatically disposed when the element is + * disconnected. If `manualDispose` is set to `true`, the effect will not + * be automatically disposed, and you must call the returned function to + * dispose of the effect manually. + */ manualDispose?: boolean; } @@ -35,38 +45,8 @@ const effectWatcher = new Signal.subtle.Watcher(() => { }); }); -/** - * Executes the provided callback function when any of the signals it accesses - * change. If an options object is provided, the `element` property can be used - * to specify the element to associate the effect with. The `beforeUpdate` - * property can be used to specify that the effect should run before the element - * updates. An effect is automatically disposed when an element specified in - * the options is disconnected. The `manualDispose` property can be set to - * `true` to prevent the effect from being automatically disposed. - * - * @param callback - * @param options {element, beforeUpdate, manualDispose} - */ -export const effect = ( - callback: () => void, - options?: ElementEffectOptions -) => { - const {element} = options ?? {}; - if (element === undefined) { - const computed = new Signal.Computed(callback); - effectWatcher.watch(computed); - Signal.subtle.untrack(() => computed.get()); - return () => { - effectWatcher.unwatch(computed); - }; - } else { - return element._effect(callback, options); - } -}; - interface SignalWatcherApi { - /** @internal */ - _effect(fn: () => void, options?: EffectOptions): () => void; + updateEffect(fn: () => void, options?: EffectOptions): () => void; } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -199,19 +179,26 @@ export function SignalWatcher>(Base: T) { if (this._watcher === undefined) { return; } + let keepAlive = false; // We unwatch all signals that are not manually disposed, so that we don't // keep the element alive by holding references to it. this._watcher.unwatch( - ...Signal.subtle - .introspectSources(this._watcher!) - .filter( - (signal) => - this.__effects.get(signal as Signal.Computed) - ?.manualDispose !== true - ) + ...Signal.subtle.introspectSources(this._watcher!).filter((signal) => { + const shouldUnwatch = + this.__effects.get(signal as Signal.Computed) + ?.manualDispose !== true; + if (shouldUnwatch) { + this.__effects.delete(signal as Signal.Computed); + } + keepAlive ||= !shouldUnwatch; + return shouldUnwatch; + }) ); - this.__performUpdateSignal = undefined; - this._watcher = undefined; + if (!keepAlive) { + this.__performUpdateSignal = undefined; + this._watcher = undefined; + this.__effects.clear(); + } } // list signals managing effects, stored with effect options. @@ -220,8 +207,19 @@ export function SignalWatcher>(Base: T) { EffectOptions | undefined >(); - /** @internal exposed via `effect` */ - _effect(fn: () => void, options?: EffectOptions): () => void { + /** + * Executes the provided callback function when any of the signals it + * accesses change. By default, the function is called after any pending + * element update. Set the `beforeUpdate` property to `true` to run the + * effect before the element updates. An effect is automatically disposed + * when the element is disconnected. Set the `manualDispose` property to + * `true` to prevent this. Call the returned function to manually dispose + * of the effect. + * + * @param callback + * @param options {beforeUpdate, manualDispose} + */ + updateEffect(fn: () => void, options?: EffectOptions): () => void { this.__watch(); const signal = new Signal.Computed(() => { fn(); @@ -241,6 +239,9 @@ export function SignalWatcher>(Base: T) { return () => { this.__effects.delete(signal); this._watcher!.unwatch(signal); + if (this.isConnected === false) { + this.__unwatch(); + } }; } diff --git a/packages/labs/signals/src/test/signal-watcher_test.ts b/packages/labs/signals/src/test/signal-watcher_test.ts index f73bfeb0c7..9b144c75dc 100644 --- a/packages/labs/signals/src/test/signal-watcher_test.ts +++ b/packages/labs/signals/src/test/signal-watcher_test.ts @@ -7,7 +7,7 @@ import {LitElement, html} from 'lit'; import {assert} from '@esm-bundle/chai'; -import {SignalWatcher, Signal, effect} from '../index.js'; +import {SignalWatcher, Signal} from '../index.js'; import {customElement, property} from 'lit/decorators.js'; let elementNameId = 0; @@ -312,7 +312,7 @@ suite('SignalWatcher', () => { assert.isTrue(survivingElements.length < elementCount); }); - test('effect notifies signal updates (after update by default)', async () => { + test('updateEffect notifies signal updates (after update by default)', async () => { const count = new Signal.State(0); const other = new Signal.State(0); let effectCount = 0; @@ -321,14 +321,11 @@ suite('SignalWatcher', () => { class TestElement extends SignalWatcher(LitElement) { constructor() { super(); - effect( - () => { - effectCount = count.get(); - effectOther = other.get(); - effectCalled++; - }, - {element: this} - ); + this.updateEffect(() => { + effectCount = count.get(); + effectOther = other.get(); + effectCalled++; + }); } override render() { return html`

count: ${count.get()}

`; @@ -375,7 +372,7 @@ suite('SignalWatcher', () => { assert.equal(effectCalled, 4); }); - test('effect notifies signal updates beforeUpdate', async () => { + test('updateEffect notifies signal updates beforeUpdate', async () => { const count = new Signal.State(0); const other = new Signal.State(0); let effectCount = 0; @@ -385,7 +382,7 @@ suite('SignalWatcher', () => { class TestElement extends SignalWatcher(LitElement) { constructor() { super(); - effect( + this.updateEffect( () => { effectTextContent = this.hasUpdated ? el.shadowRoot!.querySelector('p')!.textContent! @@ -394,7 +391,7 @@ suite('SignalWatcher', () => { effectOther = other.get(); effectCalled++; }, - {element: this, beforeUpdate: true} + {beforeUpdate: true} ); } override render() { @@ -457,12 +454,9 @@ suite('SignalWatcher', () => { container.append(el); await el.updateComplete; let effectCount = 0; - effect( - () => { - effectCount = count.get(); - }, - {element: el} - ); + el.updateEffect(() => { + effectCount = count.get(); + }); await el.updateComplete; assert.equal(effectCount, 0); el.remove(); @@ -485,12 +479,9 @@ suite('SignalWatcher', () => { container.append(el); await el.updateComplete; let effectOther = undefined; - const disposeEffect = effect( - () => { - effectOther = other.get(); - }, - {element: el} - ); + const disposeEffect = el.updateEffect(() => { + effectOther = other.get(); + }); await el.updateComplete; assert.equal(effectOther, 0); other.set(1); @@ -502,23 +493,58 @@ suite('SignalWatcher', () => { assert.equal(effectOther, 1); }); - test('standalone effects', async () => { + test('effects not disposed when manualDispose set', async () => { const count = new Signal.State(0); - const frame = () => new Promise(requestAnimationFrame); - let effectCount; - const dispose = effect(() => { - effectCount = count.get(); + class TestElement extends SignalWatcher(LitElement) { + override render() { + return html`

count: ${count.get()}

`; + } + } + customElements.define(generateElementName(), TestElement); + const el = new TestElement(); + container.append(el); + await el.updateComplete; + let effectManualCount1 = 0; + let effectManualCount2 = 0; + let effectAutoCount = 0; + const disposeEffectManual1 = el.updateEffect( + () => { + effectManualCount1 = count.get(); + }, + {manualDispose: true} + ); + const disposeEffectManual2 = el.updateEffect( + () => { + effectManualCount2 = count.get(); + }, + {manualDispose: true} + ); + el.updateEffect(() => { + effectAutoCount = count.get(); }); - await frame(); - // Called initially - assert.equal(effectCount, 0); + await el.updateComplete; + assert.equal(effectManualCount1, 0); + assert.equal(effectManualCount2, 0); + assert.equal(effectAutoCount, 0); + el.remove(); + await new Promise((r) => setTimeout(r, 0)); count.set(1); - await frame(); - assert.equal(effectCount, 1); - dispose(); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(effectManualCount1, 1); + assert.equal(effectManualCount2, 1); + assert.equal(effectAutoCount, 0); + disposeEffectManual1(); count.set(2); - await frame(); - assert.equal(effectCount, 1); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(effectManualCount1, 1); + assert.equal(effectManualCount2, 2); + assert.equal(effectAutoCount, 0); + disposeEffectManual2(); + count.set(3); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(effectManualCount1, 1); + assert.equal(effectManualCount2, 2); + assert.equal(effectAutoCount, 0); }); }); diff --git a/packages/labs/signals/src/test/watch_test.ts b/packages/labs/signals/src/test/watch_test.ts index 9c0695b611..ced8f68fbc 100644 --- a/packages/labs/signals/src/test/watch_test.ts +++ b/packages/labs/signals/src/test/watch_test.ts @@ -8,7 +8,7 @@ import {LitElement, html, render} from 'lit'; import {property} from 'lit/decorators.js'; import {cache} from 'lit/directives/cache.js'; import {assert} from '@esm-bundle/chai'; -import {watch, signal, computed, SignalWatcher, effect} from '../index.js'; +import {watch, signal, computed, SignalWatcher} from '../index.js'; let elementNameId = 0; const generateElementName = () => `test-${elementNameId++}`; @@ -165,7 +165,7 @@ suite('watch directive', () => { ); }); - test('effect + watch', async () => { + test('updateEffect + watch', async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars let renderCount = 0; const count = signal(0); @@ -183,22 +183,19 @@ suite('watch directive', () => { await el.updateComplete; let effectBeforeValue = -1; let effectBeforeDom: string | null | undefined = ''; - const disposeEffectBefore = effect( + const disposeEffectBefore = el.updateEffect( () => { effectBeforeValue = count.get(); effectBeforeDom = el.shadowRoot?.querySelector('p')?.textContent; }, - {element: el, beforeUpdate: true} + {beforeUpdate: true} ); let effectValue = -1; let effectDom: string | null | undefined = ''; - const disposeEffect = effect( - () => { - effectValue = count.get(); - effectDom = el.shadowRoot?.querySelector('p')?.textContent; - }, - {element: el} - ); + const disposeEffect = el.updateEffect(() => { + effectValue = count.get(); + effectDom = el.shadowRoot?.querySelector('p')?.textContent; + }); await el.updateComplete; assert.equal(effectBeforeValue, 0); assert.equal(effectBeforeDom, 'count: 0'); From 56de8712625e44e8a466f85de49c263e43631e8c Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Sun, 2 Nov 2025 16:15:54 -0800 Subject: [PATCH 09/11] Address review feedback; remove unused interface. --- packages/labs/signals/src/lib/signal-watcher.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/labs/signals/src/lib/signal-watcher.ts b/packages/labs/signals/src/lib/signal-watcher.ts index 5e327229b7..453b25369e 100644 --- a/packages/labs/signals/src/lib/signal-watcher.ts +++ b/packages/labs/signals/src/lib/signal-watcher.ts @@ -26,10 +26,6 @@ interface EffectOptions { manualDispose?: boolean; } -export interface ElementEffectOptions extends EffectOptions { - element?: SignalWatcher & SignalWatcherApi; -} - let effectsPending = false; const effectWatcher = new Signal.subtle.Watcher(() => { if (effectsPending) { From 0dc2832d9fe01078adaed208625ce0b55408a737 Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Thu, 6 Nov 2025 17:17:32 -0800 Subject: [PATCH 10/11] format --- dev-docs/lit-labs-lifecycle.md | 5 ----- dev-docs/lit-release-process.md | 1 - packages/labs/analyzer/CHANGELOG.md | 2 -- packages/labs/cli/CHANGELOG.md | 2 -- packages/labs/gen-manifest/CHANGELOG.md | 2 -- packages/labs/ssr-dom-shim/src/lib/events.ts | 2 +- packages/labs/ssr/CHANGELOG.md | 1 - packages/labs/virtualizer/CHANGELOG.md | 3 --- packages/labs/virtualizer/src/layouts/flow.ts | 2 +- .../src/polyfills/resize-observer-polyfill/ResizeObserver.js | 2 +- packages/lit-element/CHANGELOG.md | 1 - packages/reactive-element/src/decorators/query.ts | 4 ++-- 12 files changed, 5 insertions(+), 22 deletions(-) diff --git a/dev-docs/lit-labs-lifecycle.md b/dev-docs/lit-labs-lifecycle.md index a08e858a8e..4441ac42c5 100644 --- a/dev-docs/lit-labs-lifecycle.md +++ b/dev-docs/lit-labs-lifecycle.md @@ -173,7 +173,6 @@ Once approved, a non-labs version can be published, along with merging the docs ### Graduating a package on npm 1. Make a new package in the monorepo at `packages/{name}` - 1. Copy the source from `packages/labs/{name}` and update all the places that reference labs 2. Remove any deprecated APIs. You should not have to remove or change non-deprecated APIs. If there's a specific reason to do so, make sure you point this out in the review. If there are any breaking changes made at this point they must be reflected in the final version of the labs package published. 3. Update the version to `1.0.0`, regardless of what the version of the labs package is. @@ -181,26 +180,22 @@ Once approved, a non-labs version can be published, along with merging the docs 2. Update the labs package to re-export the production package In order to reduce code duplication, we will re-export the production package from the labs package. Labs users will get the production code on their next npm upgrade, and can start updating their imports one-by-one. - 1. Each module in the labs package must re-export the corresponding module in the production package. 2. Ideally, each symbol is re-exported along with an `@deprecated` jsdoc so that tools and IDEs will show the deprecation message and guide users to the production package. 3. Deprecate the npm package in `package.json` 4. If the production package had any breaking changes from the labs package, mark the labs package as a major in changesets. 3. Update lit.dev - 1. Remove labs labels, icons and warnings from lit.dev docs 2. Update the labs page to mark the package as graduated 4. Cleanup GitHub - 1. Close the feedback discussion. If there are remaining open items, open issues for them. 2. Update the titles of any open issues to remove the labs prefix. 5. Promotion Graduation is a big deal, make sure we tell people about it! - 1. Write a lit.dev blog post 2. Consider a release day and a small talk 3. Tweet about it diff --git a/dev-docs/lit-release-process.md b/dev-docs/lit-release-process.md index d4816e09ed..e118be504a 100644 --- a/dev-docs/lit-release-process.md +++ b/dev-docs/lit-release-process.md @@ -39,7 +39,6 @@ The end result of this document is a release of all unreleased changes on the ma ![A screenshot of a GitHub pull request title "Version Packages"](./images/lit-release-process/version-packages-pr.png) 4. Request code reviews from members of the team familiar with the packages that will be released. - 1. The purpose of this pull request is to: 1. Delete all the changeset files, moving their contents into the various Changelog files. 1. Bump the versions global. diff --git a/packages/labs/analyzer/CHANGELOG.md b/packages/labs/analyzer/CHANGELOG.md index 4c8acc4968..f7713ee3cb 100644 --- a/packages/labs/analyzer/CHANGELOG.md +++ b/packages/labs/analyzer/CHANGELOG.md @@ -142,11 +142,9 @@ - [#3655](https://github.com/lit/lit/pull/3655) [`7e20a528`](https://github.com/lit/lit/commit/7e20a5287a46eadcd06a0804147b3b27110326ad) - Added support for analyzing function declarations. - [#3529](https://github.com/lit/lit/pull/3529) [`389d0c55`](https://github.com/lit/lit/commit/389d0c558d78982d8265588d1935ede91f46f3a0) - Added CLI improvements: - - Add support for --exclude options (important for excluding test files from e.g. manifest or wrapper generation) Added more analysis support and manifest emit: - - TS enum type variables - description, summary, and deprecated for all models - module-level description & summary diff --git a/packages/labs/cli/CHANGELOG.md b/packages/labs/cli/CHANGELOG.md index 3735f7847e..6284454220 100644 --- a/packages/labs/cli/CHANGELOG.md +++ b/packages/labs/cli/CHANGELOG.md @@ -97,11 +97,9 @@ ### Minor Changes - [#3529](https://github.com/lit/lit/pull/3529) [`389d0c55`](https://github.com/lit/lit/commit/389d0c558d78982d8265588d1935ede91f46f3a0) - Added CLI improvements: - - Add support for --exclude options (important for excluding test files from e.g. manifest or wrapper generation) Added more analysis support and manifest emit: - - TS enum type variables - description, summary, and deprecated for all models - module-level description & summary diff --git a/packages/labs/gen-manifest/CHANGELOG.md b/packages/labs/gen-manifest/CHANGELOG.md index 43e3db4564..3a8121e99c 100644 --- a/packages/labs/gen-manifest/CHANGELOG.md +++ b/packages/labs/gen-manifest/CHANGELOG.md @@ -99,11 +99,9 @@ ### Minor Changes - [#3529](https://github.com/lit/lit/pull/3529) [`389d0c55`](https://github.com/lit/lit/commit/389d0c558d78982d8265588d1935ede91f46f3a0) - Added CLI improvements: - - Add support for --exclude options (important for excluding test files from e.g. manifest or wrapper generation) Added more analysis support and manifest emit: - - TS enum type variables - description, summary, and deprecated for all models - module-level description & summary diff --git a/packages/labs/ssr-dom-shim/src/lib/events.ts b/packages/labs/ssr-dom-shim/src/lib/events.ts index b33308b33a..45f0dedbf6 100644 --- a/packages/labs/ssr-dom-shim/src/lib/events.ts +++ b/packages/labs/ssr-dom-shim/src/lib/events.ts @@ -31,7 +31,7 @@ export interface EventTargetShimMeta { const isCaptureEventListener = ( options: undefined | AddEventListenerOptions | boolean -) => (typeof options === 'boolean' ? options : options?.capture ?? false); +) => (typeof options === 'boolean' ? options : (options?.capture ?? false)); // Event phases const NONE = 0; diff --git a/packages/labs/ssr/CHANGELOG.md b/packages/labs/ssr/CHANGELOG.md index 9aa6492079..cb6c6b6171 100644 --- a/packages/labs/ssr/CHANGELOG.md +++ b/packages/labs/ssr/CHANGELOG.md @@ -212,7 +212,6 @@ Including Promises in the sync iterable creates a kind of hybrid sync/async iteration protocol. Consumers of RenderResults must check each value to see if it is a Promise or iterable and wait or recurse as needed. This change introduces three new utilities to do this: - - `collectResult(result: RenderResult): Promise` - an async function that joins a RenderResult into a string. It waits for Promises and recurses into nested iterables. - `collectResultSync(result: RenderResult)` - a sync function that joins a RenderResult into a string. It recurses into nested iterables, but _throws_ when it encounters a Promise. - `RenderResultReadable` - a Node `Readable` stream implementation that provides values from a `RenderResult`. This can be piped into a `Writable` stream, or passed to web server frameworks like Koa. diff --git a/packages/labs/virtualizer/CHANGELOG.md b/packages/labs/virtualizer/CHANGELOG.md index eaa764dbd4..6f338d113f 100644 --- a/packages/labs/virtualizer/CHANGELOG.md +++ b/packages/labs/virtualizer/CHANGELOG.md @@ -200,7 +200,6 @@ _NOTE: As of this release, virtualizer is moving away from 0.x-based versioning ### Minor Changes - [#3263](https://github.com/lit/lit/pull/3263) [`4271dffa`](https://github.com/lit/lit/commit/4271dffaac2126d9b1147f87208dd3aa9c59e129) - - Add experimental masonry layout (API and behavior subject to change) - Fix [#3342: Gap miscalculation in grid base layout](https://github.com/lit/lit/issues/3342) @@ -242,7 +241,6 @@ _NOTE: As of this release, virtualizer is moving away from 0.x-based versioning - The `scroll` directive has been renamed to `virtualize`. Note that the `` element remains the recommended way to use virtualizer in most cases; the directive exists primarily for developers who are using Lit's `lit-html` templating system standalone and don't need the `LitElement` base class elsewhere in their project. - By default, a virtualizer instance is no longer itself a scroller; rather, it is a block-level container that: - - Determines its own size by calculating or estimating the total size of all of its children (both those that are currently in the DOM and those that are not) - Adds and removes children from the DOM as the visible portion of the virtualizer changes (i.e., when any of its containing ancestors, including the window, is scrolled, resized, etc.). @@ -305,7 +303,6 @@ The following are also believed to be fixed, but didn't have specific repro case - The `scroll` directive has been renamed to `virtualize`. Note that the `` element remains the recommended way to use virtualizer in most cases; the directive exists primarily for developers who are using Lit's `lit-html` templating system standalone and don't need the `LitElement` base class elsewhere in their project. - By default, a virtualizer instance is no longer itself a scroller; rather, it is a block-level container that: - - Determines its own size by calculating or estimating the total size of all of its children (both those that are currently in the DOM and those that are not) - Adds and removes children from the DOM as the visible portion of the virtualizer changes (i.e., when any of its containing ancestors, including the window, is scrolled, resized, etc.). diff --git a/packages/labs/virtualizer/src/layouts/flow.ts b/packages/labs/virtualizer/src/layouts/flow.ts index f6de4ffbc4..2ab13b9d0e 100644 --- a/packages/labs/virtualizer/src/layouts/flow.ts +++ b/packages/labs/virtualizer/src/layouts/flow.ts @@ -244,7 +244,7 @@ export class FlowLayout extends BaseLayout { const item = this._getPhysicalItem(idx); const {averageMarginSize} = this._metricsCache; return idx === 0 - ? this._metricsCache.getMarginSize(0) ?? averageMarginSize + ? (this._metricsCache.getMarginSize(0) ?? averageMarginSize) : item ? item.pos : this._estimatePosition(idx); diff --git a/packages/labs/virtualizer/src/polyfills/resize-observer-polyfill/ResizeObserver.js b/packages/labs/virtualizer/src/polyfills/resize-observer-polyfill/ResizeObserver.js index 3edf15643c..4ace3aaf02 100644 --- a/packages/labs/virtualizer/src/polyfills/resize-observer-polyfill/ResizeObserver.js +++ b/packages/labs/virtualizer/src/polyfills/resize-observer-polyfill/ResizeObserver.js @@ -206,7 +206,7 @@ var ResizeObserverController = /** @class */ (function () { ResizeObserverController.prototype.updateObservers_ = function () { // Collect observers that have active observations. var activeObservers = this.observers_.filter(function (observer) { - return observer.gatherActive(), observer.hasActive(); + return (observer.gatherActive(), observer.hasActive()); }); // Deliver notifications in a separate cycle in order to avoid any // collisions between observers, e.g. when multiple instances of diff --git a/packages/lit-element/CHANGELOG.md b/packages/lit-element/CHANGELOG.md index ccc6cb6c6a..495313560a 100644 --- a/packages/lit-element/CHANGELOG.md +++ b/packages/lit-element/CHANGELOG.md @@ -141,7 +141,6 @@ - [#3710](https://github.com/lit/lit/pull/3710) [`09949234`](https://github.com/lit/lit/commit/09949234445388d51bfb4ee24ff28a4c9f82fe17) - Add `undefined` to the return type of PropertyValues.get() - Updated dependencies: - - @lit/reactive-element@2.0.0 - lit-html@3.0.0 diff --git a/packages/reactive-element/src/decorators/query.ts b/packages/reactive-element/src/decorators/query.ts index 9edc45a54f..8bb13b2346 100644 --- a/packages/reactive-element/src/decorators/query.ts +++ b/packages/reactive-element/src/decorators/query.ts @@ -120,7 +120,7 @@ export function query(selector: string, cache?: boolean): QueryDecorator { const {get, set} = typeof nameOrContext === 'object' ? protoOrTarget - : descriptor ?? + : (descriptor ?? (() => { const key = DEV_MODE ? Symbol(`${String(nameOrContext)} (@query() cache)`) @@ -136,7 +136,7 @@ export function query(selector: string, cache?: boolean): QueryDecorator { (this as WithCache)[key] = v; }, }; - })(); + })()); return desc(protoOrTarget, nameOrContext, { get(this: ReactiveElement): V { let result: V = get!.call(this); From 36132829331725c205034ca3c98375ea1304675f Mon Sep 17 00:00:00 2001 From: Steve Orvell Date: Thu, 6 Nov 2025 17:50:08 -0800 Subject: [PATCH 11/11] fix build --- packages/labs/signals/src/lib/watch.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/labs/signals/src/lib/watch.ts b/packages/labs/signals/src/lib/watch.ts index 736139cc3d..ef5da6ef37 100644 --- a/packages/labs/signals/src/lib/watch.ts +++ b/packages/labs/signals/src/lib/watch.ts @@ -32,13 +32,16 @@ export class WatchDirective extends AsyncDirective { private __watcher?: Signal.subtle.Watcher; + private __computed: Signal.Computed | undefined; + private __watch() { if (this.__watcher !== undefined) { return; } this.__computed = new Signal.Computed(() => { - this.setValue(this.__signal?.get()); - return this.__signal?.get(); + const value = this.__signal?.get(); + this.setValue(value); + return value; }); this.__watcher = this.__host?._watcher ?? hostlessWatcher; this.__watcher.watch(this.__computed);