From afacc804181df1a327514addbb4a453b41e8be02 Mon Sep 17 00:00:00 2001 From: Justin Fagnani Date: Wed, 3 Apr 2024 19:02:20 -0700 Subject: [PATCH] WIP --- .eslintignore | 6 + .prettierignore | 6 + package-lock.json | 65 +++-- package.json | 3 + packages/labs/signals/.gitignore | 5 + packages/labs/signals/CHANGELOG.md | 1 + packages/labs/signals/LICENSE | 28 +++ packages/labs/signals/README.md | 127 ++++++++++ packages/labs/signals/package.json | 140 +++++++++++ packages/labs/signals/rollup.config.js | 17 ++ packages/labs/signals/src/index.ts | 20 ++ packages/labs/signals/src/lib/html-tag.ts | 44 ++++ .../labs/signals/src/lib/signal-watcher.ts | 107 ++++++++ packages/labs/signals/src/lib/watch.ts | 73 ++++++ .../labs/signals/src/test/html-tag_test.ts | 46 ++++ .../signals/src/test/signal-watcher_test.ts | 230 ++++++++++++++++++ packages/labs/signals/src/test/watch_test.ts | 195 +++++++++++++++ packages/labs/signals/tsconfig.json | 29 +++ packages/lit-html/src/async-directive.ts | 1 + packages/tests/src/web-test-runner.config.ts | 6 +- rollup-common.js | 1 + 21 files changed, 1125 insertions(+), 25 deletions(-) create mode 100644 packages/labs/signals/.gitignore create mode 100644 packages/labs/signals/CHANGELOG.md create mode 100644 packages/labs/signals/LICENSE create mode 100644 packages/labs/signals/README.md create mode 100644 packages/labs/signals/package.json create mode 100644 packages/labs/signals/rollup.config.js create mode 100644 packages/labs/signals/src/index.ts create mode 100644 packages/labs/signals/src/lib/html-tag.ts create mode 100644 packages/labs/signals/src/lib/signal-watcher.ts create mode 100644 packages/labs/signals/src/lib/watch.ts create mode 100644 packages/labs/signals/src/test/html-tag_test.ts create mode 100644 packages/labs/signals/src/test/signal-watcher_test.ts create mode 100644 packages/labs/signals/src/test/watch_test.ts create mode 100644 packages/labs/signals/tsconfig.json diff --git a/.eslintignore b/.eslintignore index bb0dc1e7b6..14728ebece 100644 --- a/.eslintignore +++ b/.eslintignore @@ -329,6 +329,12 @@ packages/labs/scoped-registry-mixin/development/ packages/labs/scoped-registry-mixin/test/ packages/labs/scoped-registry-mixin/node_modules/ packages/labs/scoped-registry-mixin/scoped-registry-mixin.* +packages/labs/signals/development/ +packages/labs/signals/lib/ +packages/labs/signals/node_modules/ +packages/labs/signals/test/ +packages/labs/signals/index.* + packages/labs/ssr/demo/ packages/labs/ssr/lib/ packages/labs/ssr/node_modules/ diff --git a/.prettierignore b/.prettierignore index 6f5b3c0af7..90e1528c66 100644 --- a/.prettierignore +++ b/.prettierignore @@ -317,6 +317,12 @@ packages/labs/scoped-registry-mixin/development/ packages/labs/scoped-registry-mixin/test/ packages/labs/scoped-registry-mixin/node_modules/ packages/labs/scoped-registry-mixin/scoped-registry-mixin.* +packages/labs/signals/development/ +packages/labs/signals/lib/ +packages/labs/signals/node_modules/ +packages/labs/signals/test/ +packages/labs/signals/index.* + packages/labs/ssr/demo/ packages/labs/ssr/lib/ packages/labs/ssr/node_modules/ diff --git a/package-lock.json b/package-lock.json index cc93897d45..4e0381b08c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2980,6 +2980,10 @@ "resolved": "packages/labs/scoped-registry-mixin", "link": true }, + "node_modules/@lit-labs/signals": { + "resolved": "packages/labs/signals", + "link": true + }, "node_modules/@lit-labs/ssr": { "resolved": "packages/labs/ssr", "link": true @@ -22907,6 +22911,11 @@ "version": "3.0.7", "license": "ISC" }, + "node_modules/signal-polyfill": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/signal-polyfill/-/signal-polyfill-0.1.0.tgz", + "integrity": "sha512-RMyLaEor0noasIsXmvLfRNf/QeF3eND6A1WErMqJ5ZLc3dpI7aifcZMwwpLafJkGnyLSWraK8gcXaAqxCtxxeg==" + }, "node_modules/sinon": { "version": "6.3.5", "dev": true, @@ -27418,7 +27427,7 @@ }, "packages/labs/analyzer": { "name": "@lit-labs/analyzer", - "version": "0.11.1", + "version": "0.12.0", "license": "BSD-3-Clause", "dependencies": { "package-json-type": "^1.0.3", @@ -27431,10 +27440,10 @@ }, "packages/labs/cli": { "name": "@lit-labs/cli", - "version": "0.6.2", + "version": "0.6.3", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/analyzer": "^0.11.0", + "@lit-labs/analyzer": "^0.12.0", "@lit-labs/gen-utils": "^0.3.0", "@lit/localize-tools": "^0.7.0", "chalk": "^5.0.1", @@ -27483,10 +27492,10 @@ }, "packages/labs/compiler": { "name": "@lit-labs/compiler", - "version": "1.0.2", + "version": "1.0.3", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/analyzer": "^0.11.0", + "@lit-labs/analyzer": "^0.12.0", "@parse5/tools": "^0.3.0", "lit-html": "^3.1.2", "parse5": "^7.1.2", @@ -27571,10 +27580,10 @@ }, "packages/labs/eslint-plugin": { "name": "eslint-plugin-lit", - "version": "0.0.0", + "version": "0.0.1", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/analyzer": "^0.11.1", + "@lit-labs/analyzer": "^0.12.0", "@typescript-eslint/utils": "^6.19.0", "typescript": "~5.3.3" }, @@ -27596,10 +27605,10 @@ }, "packages/labs/gen-manifest": { "name": "@lit-labs/gen-manifest", - "version": "0.3.1", + "version": "0.3.2", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/analyzer": "^0.11.0", + "@lit-labs/analyzer": "^0.12.0", "@lit-labs/gen-utils": "^0.3.0", "custom-elements-manifest": "^2.0.0" }, @@ -27620,10 +27629,10 @@ }, "packages/labs/gen-utils": { "name": "@lit-labs/gen-utils", - "version": "0.3.1", + "version": "0.3.2", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/analyzer": "^0.11.0" + "@lit-labs/analyzer": "^0.12.0" }, "devDependencies": { "@lit-internal/tests": "^0.0.1" @@ -27634,10 +27643,10 @@ }, "packages/labs/gen-wrapper-angular": { "name": "@lit-labs/gen-wrapper-angular", - "version": "0.1.2", + "version": "0.1.3", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/analyzer": "^0.11.0", + "@lit-labs/analyzer": "^0.12.0", "@lit-labs/gen-utils": "^0.3.0" }, "devDependencies": { @@ -27652,10 +27661,10 @@ }, "packages/labs/gen-wrapper-react": { "name": "@lit-labs/gen-wrapper-react", - "version": "0.3.1", + "version": "0.3.2", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/analyzer": "^0.11.0", + "@lit-labs/analyzer": "^0.12.0", "@lit-labs/gen-utils": "^0.3.0" }, "devDependencies": { @@ -27667,10 +27676,10 @@ }, "packages/labs/gen-wrapper-vue": { "name": "@lit-labs/gen-wrapper-vue", - "version": "0.3.2", + "version": "0.3.3", "license": "BSD-3-Clause", "dependencies": { - "@lit-labs/analyzer": "^0.11.0", + "@lit-labs/analyzer": "^0.12.0", "@lit-labs/gen-utils": "^0.3.0", "@lit-labs/vue-utils": "^0.1.1" }, @@ -28439,6 +28448,18 @@ "@webcomponents/scoped-custom-element-registry": "^0.0.5" } }, + "packages/labs/signals": { + "name": "@lit-labs/signals", + "version": "0.0.0", + "license": "BSD-3-Clause", + "dependencies": { + "lit": "^3.1.2", + "signal-polyfill": "^0.1.0" + }, + "devDependencies": { + "@lit-internal/scripts": "^1.0.1" + } + }, "packages/labs/ssr": { "name": "@lit-labs/ssr", "version": "3.2.2", @@ -28507,7 +28528,7 @@ "@lit-labs/ssr-client": "^1.1.7" }, "devDependencies": { - "@lit/react": "1.0.3", + "@lit/react": "1.0.4", "@types/react": "^18.0.27", "@types/react-dom": "^18.0.10", "lit": "^3.1.2", @@ -28555,10 +28576,10 @@ }, "packages/labs/test-projects/test-elements-react": { "name": "@lit-internal/test-elements-react", - "version": "1.0.4", + "version": "1.0.5", "dependencies": { "@lit-internal/test-element-a": "1.0.1", - "@lit/react": "1.0.3" + "@lit/react": "1.0.4" }, "peerDependencies": { "@types/react": "^17 || ^18", @@ -28571,7 +28592,7 @@ }, "packages/labs/testing": { "name": "@lit-labs/testing", - "version": "0.2.3", + "version": "0.2.4", "license": "BSD-3-Clause", "dependencies": { "@lit-labs/ssr": "^3.1.8", @@ -29500,7 +29521,7 @@ }, "packages/react": { "name": "@lit/react", - "version": "1.0.3", + "version": "1.0.4", "license": "BSD-3-Clause", "devDependencies": { "@lit-internal/scripts": "^1.0.1", diff --git a/package.json b/package.json index 75e03223b7..2bdd3146e5 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "./packages/labs/rollup-plugin-minify-html-literals:build", "./packages/labs/router:build", "./packages/labs/scoped-registry-mixin:build", + "./packages/labs/signals:build", "./packages/labs/ssr:build", "./packages/labs/ssr-client:build", "./packages/labs/ssr-dom-shim:build", @@ -112,6 +113,7 @@ "./packages/labs/rollup-plugin-minify-html-literals:build:ts", "./packages/labs/router:build:ts", "./packages/labs/scoped-registry-mixin:build:ts", + "./packages/labs/signals:build:ts", "./packages/labs/ssr:build:ts", "./packages/labs/ssr-client:build:ts", "./packages/labs/ssr-dom-shim:build:ts", @@ -172,6 +174,7 @@ "./packages/labs/nextjs:test", "./packages/labs/preact-signals:test", "./packages/labs/rollup-plugin-minify-html-literals:test", + "./packages/labs/signals:test", "./packages/labs/ssr:test", "./packages/labs/ssr-dom-shim:test", "./packages/labs/ssr-react:test", diff --git a/packages/labs/signals/.gitignore b/packages/labs/signals/.gitignore new file mode 100644 index 0000000000..95967737f0 --- /dev/null +++ b/packages/labs/signals/.gitignore @@ -0,0 +1,5 @@ +/development/ +/lib/ +/node_modules/ +/test/ +/index.* diff --git a/packages/labs/signals/CHANGELOG.md b/packages/labs/signals/CHANGELOG.md new file mode 100644 index 0000000000..079f00fe5a --- /dev/null +++ b/packages/labs/signals/CHANGELOG.md @@ -0,0 +1 @@ +# @lit-labs/signals diff --git a/packages/labs/signals/LICENSE b/packages/labs/signals/LICENSE new file mode 100644 index 0000000000..ab0c78590d --- /dev/null +++ b/packages/labs/signals/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2023 Google LLC. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/labs/signals/README.md b/packages/labs/signals/README.md new file mode 100644 index 0000000000..6e5fca2e36 --- /dev/null +++ b/packages/labs/signals/README.md @@ -0,0 +1,127 @@ +# @lit-labs/preact-signals + +Preact Signals integration for Lit. + +## Why Signals? + +Signals are an easy way to create shared observable state - state that many elements can use and update when it changes. This is great for things like a game state that many components need to read. + +This use case can also be covered by state management solutions like Redux or MobX, observables like RxJS, or `EventTarget`s. Signals have a nice DX balance of being granular and composable, and having a fairly simple API. + +Unlike in many frameworks, we do _not_ think that signals are going to be a big performance improvement for most Lit components. Updating Lit components is already very fast because Lit updates are batched and don't do a VDOM diff on each render; it only checks for which binding values have changed and updates the DOM for those bindings. + +A Lit element template is already somewhat like a signal-produced value: it is computed based on updates to inputs (reactive properties). The difference with Lit templates is that the data flow is push-based, rather than pull-based. Lit elements react when changes are pushed into them, whereas signals automatically subscribe to the other signals they access. These approaches are very compatible though, and we can make elements subscribe to the signals they access and trigger an update with an integration library like this one. + +## Why Preact Signals? + +There are many signal libraries now, and unfortunately they are not seamlessly compatible (we can't generically watch all signal access and run an effect when they change across libraries). So we will need to support each library individually. + +Preact Signals are a good place to start. It has integrations with other libraries; is shipped as standard JS modules; and is small, fast, and high-quality. + +## Usage + +### SignalWatcher + +`SignalWatcher` is a mixin that makes an element watch all signal accesses during the element's reactive update lifecycle, then triggers an element update when signals change. This includes signals read in `shouldUpdate()`, `willUpdate()`, `update()`, `render()`, `updated()`, `firstUpdated()`, and reactive controller's `hostUpdate()` and `hostUpdated()`. + +This effectively makes the the return result of `render()` a computed signal. + +```ts +import {LitElement, html} from 'lit'; +import {customElement, property} from 'lit'; +import {SignalWatcher, signal} from '@lit-labs/preact-signals'; + +const count = signal(0); + +@customElement('signal-example') +export class SignalExample extends SignalWatcher(LitElement) { + static styles = css` + :host { + display: block; + } + `; + + render() { + return html` +

The count is ${count.value}

+ + `; + } + + private _onClick() { + count.value = count.value + 1; + } +} +``` + +Elements should not _write_ to signals in these lifecycle methods or they might cause an infinite loop. + +### watch() directive + +The `watch()` directive accepts a single Signal and renders its value, subscribing to updates and updating the DOM when the signal changes. + +The `watch()` directive allows for very targeted updates of the DOM, which can be good for performance (but as always, measure!). The downside is that the lifecycle callbacks are not automatically watched for signal access, so values computed from signals must by wrapped in computed signals. + +```ts +import {LitElement, html} from 'lit'; +import {customElement, property} from 'lit'; +import {watch, signal} from '@lit-labs/preact-signals'; + +const count = signal(0); + +@customElement('signal-example') +export class SignalExample extends LitElement { + static styles = css` + :host { + display: block; + } + `; + + render() { + return html` +

The count is ${watch(count)}

+ + `; + } + + private _onClick() { + count.value = count.value + 1; + } +} +``` + +You can mix and match the `SignalWatcher` mixins and the `watch()` directive. When you pass a signal directly to `watch()` it is not accessed in a callback watched by `SignalWatcher`, so an update to that signal will cause a targeted DOM update and not an entire element update. + +### html tag and withWatch() + +This package also exports an `html` template tag that can be used in place of Lit's default `html` tag and automatically wraps any signals in `watch()`. + +```ts +import {LitElement} from 'lit'; +import {customElement, property} from 'lit'; +import {html, signal} from '@lit-labs/preact-signals'; + +const count = signal(0); + +@customElement('signal-example') +export class SignalExample extends LitElement { + static styles = css` + :host { + display: block; + } + `; + + render() { + return html` +

The count is ${count}

+ + `; + } + + private _onClick() { + count.value = count.value + 1; + } +} +``` + +`withWatch()` is a function that wraps an `html` tag function with the auto-watching functionality. This allows you to compose this wrapper with other html-tag wrappers like Lit's `withStatic()` static template wrapper. diff --git a/packages/labs/signals/package.json b/packages/labs/signals/package.json new file mode 100644 index 0000000000..203cd00bda --- /dev/null +++ b/packages/labs/signals/package.json @@ -0,0 +1,140 @@ +{ + "name": "@lit-labs/signals", + "version": "0.0.0", + "description": "JavaScript Signals proposal integration for Lit", + "license": "BSD-3-Clause", + "homepage": "https://lit.dev/", + "repository": { + "type": "git", + "url": "https://github.com/lit/lit.git", + "directory": "packages/labs/signals" + }, + "type": "module", + "main": "index.js", + "module": "index.js", + "typings": "index.d.ts", + "directories": { + "test": "test" + }, + "exports": { + ".": { + "types": "./development/index.d.ts", + "development": "./development/index.js", + "default": "./index.js" + } + }, + "files": [ + "/development/", + "!/development/test/", + "/index.{d.ts,d.ts.map,js,js.map}", + "/lib/" + ], + "scripts": { + "build": "wireit", + "build:ts": "wireit", + "build:ts:types": "wireit", + "test": "wireit", + "test:dev": "wireit", + "test:prod": "wireit" + }, + "wireit": { + "build": { + "dependencies": [ + "build:rollup", + "build:ts", + "build:ts:types", + "../../lit:build" + ] + }, + "build:ts": { + "command": "tsc --build --pretty", + "dependencies": [ + "../../lit:build:ts:types" + ], + "clean": "if-file-deleted", + "files": [ + "src/**/*.ts", + "src/**/*.tsx", + "tsconfig.json" + ], + "output": [ + "development", + "tsconfig.tsbuildinfo" + ] + }, + "build:ts:types": { + "command": "treemirror development . \"**/*.d.ts{,.map}\"", + "dependencies": [ + "../../internal-scripts:build", + "build:ts" + ], + "files": [], + "output": [ + "*.d.ts{,.map}", + "lib/*.d.ts{,.map}", + "test/**/*.d.ts{,.map}" + ] + }, + "build:rollup": { + "command": "rollup -c", + "dependencies": [ + "build:ts" + ], + "files": [ + "rollup.config.js", + "../../rollup-common.js", + "src/test/*_test.html" + ], + "output": [ + "index.js{,.map}", + "lib/*.js{,.map}", + "test/*_test.html" + ] + }, + "test": { + "dependencies": [ + "test:dev", + "test:prod" + ] + }, + "test:dev": { + "command": "MODE=dev node ../../tests/run-web-tests.js \"development/test/**/*_test.js\" --config ../../tests/web-test-runner.config.js", + "dependencies": [ + "build:ts", + "../../tests:build" + ], + "env": { + "BROWSERS": { + "external": true + } + }, + "files": [], + "output": [] + }, + "test:prod": { + "command": "MODE=prod node ../../tests/run-web-tests.js \"development/test/**/*_test.js\" --config ../../tests/web-test-runner.config.js", + "dependencies": [ + "build", + "../../tests:build" + ], + "env": { + "BROWSERS": { + "external": true + } + }, + "files": [], + "output": [] + } + }, + "author": "Google LLC", + "devDependencies": { + "@lit-internal/scripts": "^1.0.1" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "lit": "^3.1.2", + "signal-polyfill": "^0.1.0" + } +} diff --git a/packages/labs/signals/rollup.config.js b/packages/labs/signals/rollup.config.js new file mode 100644 index 0000000000..6558439b2c --- /dev/null +++ b/packages/labs/signals/rollup.config.js @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2018 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {litProdConfig} from '../../../rollup-common.js'; +import {createRequire} from 'module'; + +export const defaultConfig = (options = {}) => + litProdConfig({ + packageName: createRequire(import.meta.url)('./package.json').name, + entryPoints: ['index', 'lib/signal-watcher', 'lib/watch'], + ...options, + }); + +export default defaultConfig(); diff --git a/packages/labs/signals/src/index.ts b/packages/labs/signals/src/index.ts new file mode 100644 index 0000000000..b2a36cee22 --- /dev/null +++ b/packages/labs/signals/src/index.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {Signal} from 'signal-polyfill'; + +export * from 'signal-polyfill'; +export * from './lib/signal-watcher.js'; +export * from './lib/watch.js'; +export * from './lib/html-tag.js'; + +export const State = Signal.State; +export const Computed = Signal.Computed; +export const subtle = Signal.subtle; + +export const signal = (value: T) => new Signal.State(value); +export const computed = (callback: () => T) => + new Signal.Computed(callback); diff --git a/packages/labs/signals/src/lib/html-tag.ts b/packages/labs/signals/src/lib/html-tag.ts new file mode 100644 index 0000000000..2b0fa9b073 --- /dev/null +++ b/packages/labs/signals/src/lib/html-tag.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ +import { + html as coreHtml, + svg as coreSvg, + type TemplateResult, +} from 'lit/html.js'; + +import {watch} from './watch.js'; +import {Signal} from 'signal-polyfill'; + +/** + * Wraps a lit-html template tag function (`html` or `svg`) to add support for + * automatically wrapping Signal instances in the `watch()` directive. + */ +export const withWatch = + (coreTag: typeof coreHtml | typeof coreSvg) => + (strings: TemplateStringsArray, ...values: unknown[]): TemplateResult => { + // TODO (justinfagnani): use an alternative to instanceof when + // one is available. See https://github.com/preactjs/signals/issues/402 + return coreTag( + strings, + ...values.map((v) => (v instanceof Signal.State || v instanceof Signal.Computed? watch(v) : v)) + ); + }; + +/** + * Interprets a template literal as an HTML template that can efficiently + * render to and update a container. + * + * Includes signal watching support from `withWatch()`. + */ +export const html = withWatch(coreHtml); + +/** + * Interprets a template literal as an SVG template that can efficiently + * render to and update a container. + * + * Includes signal watching support from `withWatch()`. + */ +export const svg = withWatch(coreSvg); diff --git a/packages/labs/signals/src/lib/signal-watcher.ts b/packages/labs/signals/src/lib/signal-watcher.ts new file mode 100644 index 0000000000..eec5ff2bf3 --- /dev/null +++ b/packages/labs/signals/src/lib/signal-watcher.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import type {PropertyDeclaration, PropertyValueMap, 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 { + updateWatch(d: WatchDirective): void; +} + +/** + * Adds the ability for a LitElement or other ReactiveElement class to + * watch for access to signals during the update lifecycle and trigger a new + * update when signals values change. + */ +export function SignalWatcher( + Base: T +): T { + abstract class SignalWatcher extends Base { + // Watcher.watch() doesn't dedupe so we need to track this ourselves. + private __isWatching = false; + private __watcherUpdate = false; + private __watcher = new Signal.subtle.Watcher(() => { + this.__watcherUpdate = true; + this.requestUpdate(); + this.__watcherUpdate = false; + }); + private __forceUpdateSignal = new Signal.State(0); + private __updateSignal = new Signal.Computed(() => { + this.__forceUpdateSignal.get(); + super.performUpdate(); + }); + private __shouldRender = true; + private __pendingWatches = new Set>(); + + protected override performUpdate() { + // ReactiveElement.performUpdate() also does this check, so we want to + // bail early so we don't erroneously appear to not depend on any signals. + if (this.isUpdatePending === false) { + return; + } + if (this.__shouldRender) { + this.__updateSignal.get(); + } else { + super.performUpdate(); + } + } + + protected override update( + changedProperties: PropertyValueMap | Map + ): void { + if (this.__shouldRender) { + this.__shouldRender = false; + super.update(changedProperties); + } else { + this.__pendingWatches.forEach((d) => d.commmit()); + this.__pendingWatches.clear(); + } + } + + override requestUpdate( + name?: PropertyKey | undefined, + oldValue?: unknown, + options?: PropertyDeclaration | undefined + ): void { + this.__shouldRender = true; + if (!this.__watcherUpdate) { + this.__forceUpdateSignal?.set(this.__forceUpdateSignal.get() + 1); + } + super.requestUpdate(name, oldValue, options); + } + + override connectedCallback(): void { + if (!this.__isWatching) { + this.__isWatching = true; + this.__watcher.watch(this.__updateSignal); + this.requestUpdate(); + } + super.connectedCallback(); + } + + override disconnectedCallback(): void { + this.__isWatching = false; + this.__watcher.unwatch(this.__updateSignal); + super.disconnectedCallback(); + } + + updateWatch(d: WatchDirective): void { + this.__pendingWatches.add(d); + const shouldRender = this.__shouldRender; + this.__watcherUpdate = true; + this.requestUpdate(); + this.__watcherUpdate = false; + this.__shouldRender = shouldRender; + } + } + return SignalWatcher; +} diff --git a/packages/labs/signals/src/lib/watch.ts b/packages/labs/signals/src/lib/watch.ts new file mode 100644 index 0000000000..22eb4bbb35 --- /dev/null +++ b/packages/labs/signals/src/lib/watch.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {DirectiveResult, Part, directive} from 'lit/directive.js'; +import {AsyncDirective} from 'lit/async-directive.js'; +import {Signal} from 'signal-polyfill'; +import {SignalWatcher} from './signal-watcher.js'; + +export class WatchDirective extends AsyncDirective { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private __host?: SignalWatcher; + __signal?: Signal.State | Signal.Computed; + private __watcher = new Signal.subtle.Watcher(() => { + this.__host?.updateWatch(this as WatchDirective); + }); + + commmit() { + this.setValue(Signal.subtle.untrack(() => this.__signal?.get())); + } + + // @ts-expect-error: signal is unused, but the name appears in the signature + // eslint-disable-next-line @typescript-eslint/no-unused-vars + render(signal: Signal.State | Signal.Computed): T { + return undefined as T; + } + + override update( + part: Part, + [signal]: [signal: Signal.State | Signal.Computed] + ) { + this.__host ??= part.options?.host as SignalWatcher; + if (signal !== this.__signal) { + if (this.__signal !== undefined) { + this.__watcher.unwatch(this.__signal); + } + if (signal !== undefined) { + this.__watcher.watch(signal); + } + this.__signal = signal; + } + + // 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(() => signal.get()); + } + + protected override disconnected(): void { + if (this.__signal !== undefined) { + this.__watcher.unwatch(this.__signal); + } + } + + protected override reconnected(): void { + if (this.__signal !== undefined) { + this.__watcher.watch(this.__signal); + } + } +} + +export type WatchDirectiveFunction = ( + signal: Signal.State | Signal.Computed +) => DirectiveResult>; + +/** + * Renders a signal and subscribes to it, updating the part when the signal + * changes. + */ +export const watch = directive(WatchDirective) as WatchDirectiveFunction; diff --git a/packages/labs/signals/src/test/html-tag_test.ts b/packages/labs/signals/src/test/html-tag_test.ts new file mode 100644 index 0000000000..6913c858e2 --- /dev/null +++ b/packages/labs/signals/src/test/html-tag_test.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {LitElement} from 'lit'; +import {assert} from '@esm-bundle/chai'; + +import {SignalWatcher, html, signal} from '../index.js'; + +let elementNameId = 0; +const generateElementName = () => `test-${elementNameId++}`; + +suite('html tag', () => { + let container: HTMLElement; + + setup(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + teardown(() => { + container?.remove(); + }); + + test('watches a signal', async () => { + const count = signal(0); + class TestElement extends SignalWatcher(LitElement) { + override render() { + return html`

count: ${count}

`; + } + } + customElements.define(generateElementName(), TestElement); + const el = new TestElement(); + container.append(el); + + await el.updateComplete; + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 0'); + + count.set(1); + await el.updateComplete; + + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 1'); + }); +}); diff --git a/packages/labs/signals/src/test/signal-watcher_test.ts b/packages/labs/signals/src/test/signal-watcher_test.ts new file mode 100644 index 0000000000..782efd32b8 --- /dev/null +++ b/packages/labs/signals/src/test/signal-watcher_test.ts @@ -0,0 +1,230 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {LitElement, html} from 'lit'; +import {assert} from '@esm-bundle/chai'; + +import {SignalWatcher, Signal} from '../index.js'; +import {property} from 'lit/decorators.js'; + +let elementNameId = 0; +const generateElementName = () => `test-${elementNameId++}`; + +suite('SignalWatcher', () => { + let container: HTMLElement; + + setup(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + teardown(() => { + container?.remove(); + }); + + test('watches a signal', 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; + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 0'); + + count.set(1); + // await new Promise((r) => setTimeout(r, 0)); + await el.updateComplete; + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 1'); + }); + + test('non-signal updates work', async () => { + const count = new Signal.State(0); + let renderCount = 0; + class TestElement extends SignalWatcher(LitElement) { + @property() + foo = 'foo'; + + override render() { + renderCount++; + return html` +

count: ${count.get()}

+

foo: ${this.foo}

+ `; + } + } + customElements.define(generateElementName(), TestElement); + const el = new TestElement(); + container.append(el); + + await el.updateComplete; + const p1 = el.shadowRoot!.querySelectorAll('p')[0]!; + const p2 = el.shadowRoot!.querySelectorAll('p')[1]!; + + assert.equal(p1.textContent, 'count: 0'); + assert.equal(p2.textContent, 'foo: foo'); + assert.equal(renderCount, 1); + + count.set(1); + await el.updateComplete; + assert.equal(p1.textContent, 'count: 1'); + assert.equal(p2.textContent, 'foo: foo'); + assert.equal(renderCount, 2); + + el.foo = 'bar'; + await el.updateComplete; + assert.equal(p1.textContent, 'count: 1'); + assert.equal(p2.textContent, 'foo: bar'); + assert.equal(renderCount, 3); + + count.set(2); + el.foo = 'baz'; + await el.updateComplete; + assert.equal(p1.textContent, 'count: 2'); + assert.equal(p2.textContent, 'foo: baz'); + assert.equal(renderCount, 4); + }); + + test('unsubscribes to a signal on element disconnect', async () => { + let readCount = 0; + const count = new Signal.State(0); + const countPlusOne = new Signal.Computed(() => { + readCount++; + return count.get() + 1; + }); + + class TestElement extends SignalWatcher(LitElement) { + override render() { + return html`

count: ${countPlusOne.get()}

`; + } + } + customElements.define(generateElementName(), TestElement); + const el = new TestElement(); + container.append(el); + + // First render, expect one read of the signal + await el.updateComplete; + assert.equal( + el.shadowRoot?.querySelector('p')?.textContent, + 'count: 1', + 'A' + ); + assert.equal(readCount, 1); + + // Updates work + count.set(1); + await el.updateComplete; + assert.equal( + el.shadowRoot?.querySelector('p')?.textContent, + 'count: 2', + 'B' + ); + assert.equal(readCount, 2); + + // Disconnect the element + el.remove(); + await el.updateComplete; + + // Expect no reads while disconnected + count.set(2); + assert.equal( + el.shadowRoot?.querySelector('p')?.textContent, + 'count: 2', + 'C' + ); + assert.equal(readCount, 2); + + // Even after an update + await el.updateComplete; + assert.equal( + el.shadowRoot?.querySelector('p')?.textContent, + 'count: 2', + 'D' + ); + assert.equal(readCount, 2); + + // Reconnect the element + container.append(el); + assert.isTrue(el.isConnected); + // The mixin causes the element to update on re-connect + assert.isTrue(el.isUpdatePending); + + // So when reconnected, we still have the old value + assert.equal( + el.shadowRoot?.querySelector('p')?.textContent, + 'count: 2', + 'E' + ); + assert.equal(readCount, 2); + + // But after an update, we get the new value + await el.updateComplete; + // countPlusOne is not being dirtied?? + assert.equal(countPlusOne.get(), 3, 'How is this failing?'); + assert.equal( + el.shadowRoot?.querySelector('p')?.textContent, + 'count: 3', + 'F' + ); + + // When signals change again we get the new value + count.set(3); + assert.isTrue(el.isUpdatePending); + await el.updateComplete; + assert.equal( + el.shadowRoot?.querySelector('p')?.textContent, + 'count: 4', + 'G' + ); + assert.equal(readCount, 3); + }); + + test('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; + } + abstract class BaseEl extends LitElement { + abstract foo(): void; + } + // @ts-expect-error foo() needs to be implemented. + class TestEl extends SignalWatcher(BaseEl) {} + console.log(TestEl); // usage to satisfy eslint. + + const TestElFromAbstractSignalWatcher = SignalWatcher(BaseEl); + // @ts-expect-error cannot instantiate an abstract class. + new TestElFromAbstractSignalWatcher(); + + // This is fine, passed in class is not abstract. + const TestElFromConcreteClass = SignalWatcher(LitElement); + new TestElFromConcreteClass(); + }); + + test('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() { + return html`

count: ${count.get()}

`; + } + } + const TestElWithSignalWatcher = SignalWatcher(TestEl); + customElements.define(generateElementName(), TestElWithSignalWatcher); + const el = new TestElWithSignalWatcher(); + container.append(el); + + await el.updateComplete; + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 0'); + + count.set(count.get() + 1); + + await el.updateComplete; + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 1'); + }); +}); diff --git a/packages/labs/signals/src/test/watch_test.ts b/packages/labs/signals/src/test/watch_test.ts new file mode 100644 index 0000000000..c01b949c72 --- /dev/null +++ b/packages/labs/signals/src/test/watch_test.ts @@ -0,0 +1,195 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import {LitElement, html} 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'; + +let elementNameId = 0; +const generateElementName = () => `test-${elementNameId++}`; + +suite('watch directive', () => { + let container: HTMLElement; + + setup(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + teardown(() => { + container?.remove(); + }); + + test('watches a signal', async () => { + 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; + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 0', 'A'); + assert.equal(renderCount, 1); + assert.isFalse(el.isUpdatePending); + + // The DOM updates because signal update + count.set(1); + assert.isTrue(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('unsubscribes to a signal on element disconnect', async () => { + let readCount = 0; + const count = signal(0); + const countPlusOne = computed(() => { + readCount++; + return count.get() + 1; + }); + + class TestElement extends SignalWatcher(LitElement) { + override render() { + return html`

count: ${watch(countPlusOne)}

`; + } + } + customElements.define(generateElementName(), TestElement); + const el = new TestElement(); + container.append(el); + + // First render, expect one read of the signal + await el.updateComplete; + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 1'); + assert.equal(readCount, 1); + + // Force the directive to disconnect + el.remove(); + await el.updateComplete; + + // Expect no reads while disconnected + count.set(1); + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 1'); + assert.equal(readCount, 1); + + // Even after an update + await el.updateComplete; + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 1'); + assert.equal(readCount, 1); + + // Force the directive to reconnect + container.append(el); + // Elements do *not* automatically render when re-connected + assert.isFalse(el.isUpdatePending); + + // So when reconnected, we read the signal value again + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 2'); + assert.equal(readCount, 2); + + // And signal updates propagate again + count.set(2); + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 3'); + assert.equal(readCount, 3); + }); + + test('unsubscribes to a signal on directive disconnect', async () => { + let readCount = 0; + const count = signal(0); + const countPlusOne = computed(() => { + readCount++; + return count.get() + 1; + }); + + class TestElement extends SignalWatcher(LitElement) { + @property() renderWithSignal = true; + + signalTemplate = html`${watch(countPlusOne)}`; + + stringTemplate = html`string`; + + override render() { + const t = this.renderWithSignal + ? this.signalTemplate + : this.stringTemplate; + // Cache the expression so that we preserve the directive instance + // and trigger the reconnected code-path. + // TODO (justinfagnani): it would be nice if we could assert that we + // really did trigger reconnected instead of rendering a new directive, + // but we don't want to code the directive to specifically leave a trace + // of reconnected-ness. + return html`

value: ${cache(t)}

`; + } + } + customElements.define(generateElementName(), TestElement); + const el = new TestElement(); + container.append(el); + + // First render with the signal, expect one read of the signal + await el.updateComplete; + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'value: 1'); + assert.equal(readCount, 1); + + // Render with a non-signal + el.renderWithSignal = false; + await el.updateComplete; + + // Expect no reads while disconnected + count.set(1); + assert.equal( + el.shadowRoot?.querySelector('p')?.textContent, + 'value: string' + ); + assert.equal(readCount, 1); + + // Render with the signal again + el.renderWithSignal = true; + await el.updateComplete; + + // Render should use the new value + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'value: 2'); + assert.equal(readCount, 2); + + // And signal updates propagate again + count.set(2); + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'value: 3'); + assert.equal(readCount, 3); + }); + + test('does not trigger an element update', async () => { + 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); + + await el.updateComplete; + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 0'); + assert.equal(renderCount, 1); + + count.set(1); + assert.equal(el.shadowRoot?.querySelector('p')?.textContent, 'count: 1'); + // The updated DOM is not because of an element render + assert.equal(renderCount, 1, 'A'); + // The signal update does not trigger a render + assert.equal(el.isUpdatePending, false); + }); +}); diff --git a/packages/labs/signals/tsconfig.json b/packages/labs/signals/tsconfig.json new file mode 100644 index 0000000000..00529fcf93 --- /dev/null +++ b/packages/labs/signals/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "composite": true, + "target": "es2019", + "module": "NodeNext", + "lib": ["es2020", "DOM", "DOM.Iterable"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "inlineSources": true, + "outDir": "./development/", + "rootDir": "./src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitThis": true, + "moduleResolution": "NodeNext", + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "noImplicitOverride": true, + "skipLibCheck": true, + "types": ["mocha"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": [] +} diff --git a/packages/lit-html/src/async-directive.ts b/packages/lit-html/src/async-directive.ts index dfae2a2a28..9ae4041d20 100644 --- a/packages/lit-html/src/async-directive.ts +++ b/packages/lit-html/src/async-directive.ts @@ -362,6 +362,7 @@ export abstract class AsyncDirective extends Directive { * @param value The value to set */ setValue(value: unknown) { + // this.__part.options?.host if (isSingleExpression(this.__part as unknown as PartInfo)) { this.__part._$setValue(value, this); } else { diff --git a/packages/tests/src/web-test-runner.config.ts b/packages/tests/src/web-test-runner.config.ts index 75cd5754c2..e3dea610d4 100644 --- a/packages/tests/src/web-test-runner.config.ts +++ b/packages/tests/src/web-test-runner.config.ts @@ -28,8 +28,8 @@ const browserPresets = { // Default set of Playwright browsers to test when running locally. local: [ 'chromium', // keep browsers on separate lines - 'firefox', // to make it easier to comment out - 'webkit', // individual browsers + // 'firefox', // to make it easier to comment out + // 'webkit', // individual browsers ], // Browsers to test during automated continuous integration. @@ -219,7 +219,7 @@ const config: TestRunnerConfig = { }) as TestRunnerPlugin, ], // Only actually log errors. This helps make test output less spammy. - filterBrowserLogs: ({type}) => type === 'error', + // filterBrowserLogs: ({type}) => type === 'error', middleware: [ /** * Ensures that when we're in dev mode we only load dev sources, and when diff --git a/rollup-common.js b/rollup-common.js index b9385a96fc..ea5bb3c498 100644 --- a/rollup-common.js +++ b/rollup-common.js @@ -37,6 +37,7 @@ const PACKAGE_CLASS_PREFIXES = { '@lit/task': '_$P', '@lit/context': '_$Q', '@lit/react': '_$R', + '@lit-labs/signals': '_$S', }; // Validate prefix uniqueness