Skip to content

Commit 6c174c8

Browse files
[reactive-element] Fix a bug in change detection with decorated standard private accessors (#4999)
1 parent 956d957 commit 6c174c8

File tree

8 files changed

+148
-9
lines changed

8 files changed

+148
-9
lines changed

.changeset/proud-pets-draw.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@lit/reactive-element': patch
3+
'lit': patch
4+
'lit-element': patch
5+
---
6+
7+
Fix a bug in change detection with decorated standard private accessors.

packages/reactive-element/package.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@
251251
],
252252
"files": [
253253
"src/**/*.ts",
254+
"!src/test/decorators-modern/decorators-standard/**/*.ts",
254255
"tsconfig.json",
255256
"tsconfig.polyfill-support.json"
256257
],
@@ -274,7 +275,8 @@
274275
"decorators/*.d.ts{,.map}",
275276
"legacy-decorators/*.d.ts{,.map}",
276277
"std-decorators/*.d.ts{,.map}"
277-
]
278+
],
279+
"clean": "if-file-deleted"
278280
},
279281
"build:ts:std-decorators-tests": {
280282
"#comment": "This is a separate script from build:ts because it needs a tsconfig without experimentalDecorators.",
@@ -285,6 +287,7 @@
285287
],
286288
"files": [
287289
"src/test/decorators-modern/**/*.ts",
290+
"src/test/decorators-standard/**/*.ts",
288291
"tsconfig.std-decorators-tests.json"
289292
],
290293
"output": [
@@ -318,7 +321,7 @@
318321
]
319322
},
320323
"build:babel": {
321-
"command": "babel --extensions .ts src/test/decorators-modern --out-dir development/test/decorators-babel",
324+
"command": "babel --extensions .ts src/test/decorators-modern/*_test.ts --out-dir development/test/decorators-babel",
322325
"files": [
323326
".babelrc",
324327
"src/test/decorators-modern/**/*.ts"
@@ -330,7 +333,8 @@
330333
"build:esbuild": {
331334
"command": "esbuild src/test/decorators-modern/**/*.ts --outdir=development/test/decorators-esbuild --target=es2022",
332335
"files": [
333-
"src/test/decorators-modern/**/*.ts"
336+
"src/test/decorators-modern/**/*.ts",
337+
"!src/test/decorators-modern/decorators-standard/**/*.ts"
334338
],
335339
"output": [
336340
"development/test/decorators-esbuild"

packages/reactive-element/src/decorators/property.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export const standardProperty = <C extends Interface<ReactiveElement>, V>(
150150
this as unknown as C,
151151
v
152152
);
153-
this.requestUpdate(name, oldValue, options);
153+
this.requestUpdate(name, oldValue, options, true, v);
154154
},
155155
init(this: ReactiveElement, v: V): V {
156156
if (v !== undefined) {
@@ -164,7 +164,7 @@ export const standardProperty = <C extends Interface<ReactiveElement>, V>(
164164
return function (this: ReactiveElement, value: V) {
165165
const oldValue = this[name as keyof ReactiveElement];
166166
(target as (value: V) => void).call(this, value);
167-
this.requestUpdate(name, oldValue, options);
167+
this.requestUpdate(name, oldValue, options, true, value);
168168
} as unknown as (this: C, value: V) => void;
169169
}
170170
throw new Error(`Unsupported decorator location: ${kind}`);

packages/reactive-element/src/reactive-element.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,12 +1270,20 @@ export abstract class ReactiveElement
12701270
* @param oldValue old value of requesting property
12711271
* @param options property options to use instead of the previously
12721272
* configured options
1273+
* @param useNewValue if true, the newValue argument is used instead of
1274+
* reading the property value. This is important to use if the reactive
1275+
* property is a standard private accessor, as opposed to a plain
1276+
* property, since private members can't be dynamically read by name.
1277+
* @param newValue the new value of the property. This is only used if
1278+
* `useNewValue` is true.
12731279
* @category updates
12741280
*/
12751281
requestUpdate(
12761282
name?: PropertyKey,
12771283
oldValue?: unknown,
1278-
options?: PropertyDeclaration
1284+
options?: PropertyDeclaration,
1285+
useNewValue = false,
1286+
newValue?: unknown
12791287
): void {
12801288
// If we have a property key, perform property update steps.
12811289
if (name !== undefined) {
@@ -1286,7 +1294,9 @@ export abstract class ReactiveElement
12861294
);
12871295
}
12881296
const ctor = this.constructor as typeof ReactiveElement;
1289-
const newValue = this[name as keyof this];
1297+
if (useNewValue === false) {
1298+
newValue = this[name as keyof this];
1299+
}
12901300
options ??= ctor.getPropertyOptions(name);
12911301
const changed =
12921302
(options.hasChanged ?? notEqual)(newValue, oldValue) ||
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
This folder is excluded from the plain tsconfig that uses
2+
`experimentalDecorators: true` because it includes decorators on private class
3+
members which aren't allowed by experimental decorators.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
*/
6+
7+
import {ReactiveElement, PropertyValues} from '@lit/reactive-element';
8+
import {state} from '@lit/reactive-element/decorators/state.js';
9+
import {generateElementName} from '../../test-helpers.js';
10+
import {assert} from 'chai';
11+
12+
suite('@state', () => {
13+
let container: HTMLElement;
14+
let el: E;
15+
16+
const hasChanged = (value: any, old: any) => old === undefined || value > old;
17+
18+
class E extends ReactiveElement {
19+
@state()
20+
accessor #prop: string | undefined = undefined;
21+
22+
get prop() {
23+
return this.#prop;
24+
}
25+
26+
set prop(v) {
27+
this.#prop = v;
28+
}
29+
30+
@state({hasChanged})
31+
accessor #hasChangedProp = 10;
32+
33+
get hasChangedProp() {
34+
return this.#hasChangedProp;
35+
}
36+
37+
set hasChangedProp(v) {
38+
this.#hasChangedProp = v;
39+
}
40+
41+
#setterValue: string | undefined = undefined;
42+
43+
@state()
44+
set #setter(v: string | undefined) {
45+
this.#setterValue = v;
46+
}
47+
get #setter() {
48+
return this.#setterValue;
49+
}
50+
51+
set setter(v: string | undefined) {
52+
this.#setter = v;
53+
}
54+
get setter() {
55+
return this.#setter;
56+
}
57+
58+
updateCount = 0;
59+
60+
override update(changed: PropertyValues) {
61+
this.updateCount++;
62+
super.update(changed);
63+
}
64+
}
65+
customElements.define(generateElementName(), E);
66+
67+
setup(async () => {
68+
container = document.createElement('div');
69+
container.id = 'test-container';
70+
document.body.appendChild(container);
71+
el = new E();
72+
container.appendChild(el);
73+
await el.updateComplete;
74+
});
75+
76+
teardown(() => {
77+
if (container !== undefined) {
78+
container.parentElement!.removeChild(container);
79+
(container as any) = undefined;
80+
}
81+
});
82+
83+
test('triggers accessor update', async () => {
84+
assert.equal(el.updateCount, 1);
85+
el.prop = 'change';
86+
await el.updateComplete;
87+
// If we don't read the new value of private accessors correctly, this will
88+
// fail.
89+
assert.equal(el.updateCount, 2);
90+
});
91+
92+
test('uses hasChanged', async () => {
93+
assert.equal(el.updateCount, 1);
94+
el.hasChangedProp = 100;
95+
await el.updateComplete;
96+
// If we don't read the new value of private accessors correctly, this will
97+
// fail.
98+
assert.equal(el.updateCount, 2);
99+
el.hasChangedProp = 0;
100+
await el.updateComplete;
101+
assert.equal(el.updateCount, 2);
102+
});
103+
104+
test('triggers setter update', async () => {
105+
assert.equal(el.updateCount, 1);
106+
el.setter = 'change';
107+
await el.updateComplete;
108+
// If we don't read the new value of private accessors correctly, this will
109+
// fail.
110+
assert.equal(el.updateCount, 2);
111+
});
112+
});

packages/reactive-element/tsconfig.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
"../../node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.d.ts",
2929
"src/**/*.ts"
3030
],
31-
"exclude": ["src/polyfill-support.ts"],
31+
"exclude": [
32+
"src/polyfill-support.ts",
33+
"src/test/decorators-modern/decorators-standard/**/*.ts"
34+
],
3235
"references": [{"path": "./tsconfig.polyfill-support.json"}]
3336
}

scripts/check-size.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as fs from 'fs';
99
// it's likely that we'll ask you to investigate ways to reduce the size.
1010
//
1111
// In either case, update the size here and push a new commit to your PR.
12-
const expectedLitCoreSize = 15717;
12+
const expectedLitCoreSize = 15734;
1313
const expectedLitHtmlSize = 7309;
1414

1515
const litCoreSrc = fs.readFileSync('packages/lit/lit-core.min.js', 'utf8');

0 commit comments

Comments
 (0)