Skip to content

[labs/signals] watch prevents re-render when an unrelated signal changes #4860

@tjcrowder

Description

@tjcrowder

Which package(s) are affected?

Other/unknown (please mention in description)

Description

Using @lit-labs/signals, using watch on one signal sometimes prevents render from being called when an unrelated signal changes. It doesn't appear to happen with all other signals, but I have a case that replicates it. Here's the component:

import { LitElement, css, html } from "lit";
import { customElement } from "lit/decorators.js";
import { signal, SignalWatcher, watch } from "@lit-labs/signals";

@customElement("lit-list")
export class LitListElement extends SignalWatcher(LitElement) {
    #items = signal<string[]>([]);

    #item = signal("");

    render() {
        const items = this.#items.get();
        console.log(`render: ${items.length}`);
        return html`<div>
            <div>
                <label
                    >Item:
                    <input
                        type="text"
                        .value=${watch(this.#item)}
                        @input=${this.#itemInput}
                /></label>
                <button @click=${this.#onAddItemClick}>Add</button>
            </div>
            <ul>
                ${items.map((item) => html`<li>${item}</li>`)}
            </ul>
        </div> `;
    }

    #itemInput(e: InputEvent) {
        const value = (e.target as HTMLInputElement).value;
        this.#item.set(value);
    }

    #onAddItemClick(e: MouseEvent) {
        e.preventDefault();
        const item = this.#item.get();
        const items = this.#items.get();
        const newItems = [...items, item];
        console.log(
            `Adding "${item}" to [${items.map((item) => `"${item}"`)}] (${
                items.length
            }) => [${newItems.map((item) => `"${item}"`)}] (${newItems.length})`
        );
        this.#items.set(newItems);
    }

    static styles = css`
        :host {
            display: block;
        }
    `;
}

declare global {
    interface HTMLElementTagNameMap {
        "lit-list": LitListElement;
    }
}

this.#item is a signal around a string for the current item being typed, and this.#items is a signal around an array for the list of items. With the code above, typing an item and clicking Add does add the item to this.#items, but doesn't cause a re-render. As I understand it, since we're reading the value of the this.#items signal within render, changing it should cause a re-render.

If you change the .value on the input, though, from .value=${watch(this.#item)} to .value=${this.#item.get()} (e.g., don't use watch), the problem goes away and the component re-renders when this.#items changes.

(Note that I'm setting an entirely new array when updating this.#items, not just pushing to it, so it's not the classic issue of it being the same array. [The same problem happens if I use SignalArray from signal-utils, but I wanted a minimal example.])

Reproduction

The code above replicates the issue. Here's a live example: https://stackblitz.com/edit/vitejs-vite-heivabvr?file=src%2Flit-list-element.ts

Workaround

The workaround is to not use watch.

Is this a regression?

No or unsure. This never worked, or I haven't tried before.

Affected versions

Lit v3.2.1, @lit-labs/signals v0.1.1

Browser/OS/Node environment

Browser: Chromium 131.0.6778.85 (Official Build) for Linux Mint (64-bit)
OS: Linux Mint 21
Node: v23.3.0
npm: v10.9.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    Status

    ✅ Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions