Skip to content

Adds useDefault property option#4934

Merged
sorvell merged 28 commits intomainfrom
skip-initial
Mar 8, 2025
Merged

Adds useDefault property option#4934
sorvell merged 28 commits intomainfrom
skip-initial

Conversation

@sorvell
Copy link
Member

@sorvell sorvell commented Feb 19, 2025

Note: This is an alternative to #4895, only one should be merged.

Adds support for lit/rfcs#36 and helps facilitate shoelace-style/shoelace#2339.

Spawning an initial attribute can cause a NextJS hydration error, see repro.

To use, set useDefault in property options, and make sure to set an initial value in the field/accessor.

@property({reflect: true, useDefault: true})
accessor variant = 'foo';

Note, using accessor is encouraged but not required. This setting can also be used in static properties and on custom getters/setters.

Previously, this would spawn a variant="foo" attribute. With this PR and the above settings, it will not.

<my-element></my-element>

In addition, the initial value will be set when the attribute is removed.

@changeset-bot
Copy link

changeset-bot bot commented Feb 19, 2025

🦋 Changeset detected

Latest commit: b3d4fb0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@lit/reactive-element Minor
lit Minor
lit-element Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Contributor

github-actions bot commented Feb 19, 2025

📊 Tachometer Benchmark Results

Summary

nop-update

  • this-change, tip-of-tree, previous-release: unsure 🔍 -4% - +14% (-0.46ms - +1.70ms)
    this-change vs tip-of-tree

render

  • this-change: 43.63ms - 54.56ms
  • this-change, tip-of-tree, previous-release: unsure 🔍 -3% - +4% (-0.49ms - +0.70ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -2% - +1% (-0.58ms - +0.36ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -22% - +32% (-11.94ms - +18.08ms)
    this-change vs tip-of-tree

update

  • this-change: 497.73ms - 505.60ms
  • this-change, tip-of-tree, previous-release: unsure 🔍 -2% - +6% (-0.71ms - +2.41ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -2% - +3% (-1.31ms - +2.24ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -1% - +1% (-6.57ms - +7.38ms)
    this-change vs tip-of-tree

update-reflect

  • this-change: 493.69ms - 500.74ms
  • this-change, tip-of-tree, previous-release: unsure 🔍 -1% - +2% (-3.41ms - +11.11ms)
    this-change vs tip-of-tree

Results

this-change

render

VersionAvg timevs
45.47ms - 58.15ms-

update

VersionAvg timevs
516.83ms - 523.79ms-

update-reflect

VersionAvg timevs
513.14ms - 517.96ms-
this-change, tip-of-tree, previous-release

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
19.16ms - 20.29ms-unsure 🔍
-3% - +3%
-0.63ms - +0.67ms
unsure 🔍
-3% - +5%
-0.53ms - +0.93ms
tip-of-tree
tip-of-tree
19.38ms - 20.03msunsure 🔍
-3% - +3%
-0.67ms - +0.63ms
-unsure 🔍
-2% - +4%
-0.39ms - +0.75ms
previous-release
previous-release
19.06ms - 19.99msunsure 🔍
-5% - +3%
-0.93ms - +0.53ms
unsure 🔍
-4% - +2%
-0.75ms - +0.39ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
39.42ms - 44.22ms-unsure 🔍
-3% - +10%
-1.25ms - +3.92ms
unsure 🔍
-5% - +8%
-2.20ms - +3.11ms
tip-of-tree
tip-of-tree
39.52ms - 41.45msunsure 🔍
-9% - +3%
-3.92ms - +1.25ms
-unsure 🔍
-6% - +1%
-2.36ms - +0.61ms
previous-release
previous-release
40.23ms - 42.49msunsure 🔍
-7% - +5%
-3.11ms - +2.20ms
unsure 🔍
-2% - +6%
-0.61ms - +2.36ms
-

nop-update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
12.15ms - 13.97ms-unsure 🔍
-4% - +16%
-0.45ms - +1.91ms
unsure 🔍
-9% - +10%
-1.12ms - +1.31ms
tip-of-tree
tip-of-tree
11.58ms - 13.09msunsure 🔍
-14% - +3%
-1.91ms - +0.45ms
-unsure 🔍
-13% - +3%
-1.74ms - +0.47ms
previous-release
previous-release
12.16ms - 13.78msunsure 🔍
-10% - +9%
-1.31ms - +1.12ms
unsure 🔍
-4% - +14%
-0.47ms - +1.74ms
-
this-change, tip-of-tree, previous-release

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
37.68ms - 38.54ms-unsure 🔍
-1% - +2%
-0.55ms - +0.92ms
unsure 🔍
-2% - +2%
-0.74ms - +0.69ms
tip-of-tree
tip-of-tree
37.33ms - 38.52msunsure 🔍
-2% - +1%
-0.92ms - +0.55ms
-unsure 🔍
-3% - +2%
-1.03ms - +0.62ms
previous-release
previous-release
37.56ms - 38.70msunsure 🔍
-2% - +2%
-0.69ms - +0.74ms
unsure 🔍
-2% - +3%
-0.62ms - +1.03ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
76.79ms - 80.74ms-unsure 🔍
-4% - +3%
-3.36ms - +2.37ms
unsure 🔍
-3% - +4%
-2.69ms - +3.13ms
tip-of-tree
tip-of-tree
77.19ms - 81.33msunsure 🔍
-3% - +4%
-2.37ms - +3.36ms
-unsure 🔍
-3% - +5%
-2.26ms - +3.68ms
previous-release
previous-release
76.42ms - 80.67msunsure 🔍
-4% - +3%
-3.13ms - +2.69ms
unsure 🔍
-5% - +3%
-3.68ms - +2.26ms
-
this-change, tip-of-tree, previous-release

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
48.72ms - 70.65ms-unsure 🔍
-22% - +34%
-11.67ms - +18.68ms
unsure 🔍
-20% - +37%
-10.65ms - +19.60ms
tip-of-tree
tip-of-tree
45.69ms - 66.67msunsure 🔍
-31% - +19%
-18.68ms - +11.67ms
-unsure 🔍
-25% - +29%
-13.81ms - +15.75ms
previous-release
previous-release
44.80ms - 65.62msunsure 🔍
-32% - +17%
-19.60ms - +10.65ms
unsure 🔍
-28% - +24%
-15.75ms - +13.81ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
526.35ms - 538.41ms-unsure 🔍
-2% - +1%
-12.04ms - +5.73ms
unsure 🔍
-2% - +1%
-10.91ms - +6.33ms
tip-of-tree
tip-of-tree
529.00ms - 542.06msunsure 🔍
-1% - +2%
-5.73ms - +12.04ms
-unsure 🔍
-2% - +2%
-8.11ms - +9.84ms
previous-release
previous-release
528.51ms - 540.83msunsure 🔍
-1% - +2%
-6.33ms - +10.91ms
unsure 🔍
-2% - +2%
-9.84ms - +8.11ms
-

update-reflect

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
541.23ms - 550.30ms-unsure 🔍
-2% - +1%
-8.34ms - +7.42ms
unsure 🔍
-1% - +1%
-6.34ms - +7.64ms
tip-of-tree
tip-of-tree
539.78ms - 552.67msunsure 🔍
-1% - +2%
-7.42ms - +8.34ms
-unsure 🔍
-1% - +2%
-7.25ms - +9.46ms
previous-release
previous-release
539.80ms - 550.43msunsure 🔍
-1% - +1%
-7.64ms - +6.34ms
unsure 🔍
-2% - +1%
-9.46ms - +7.25ms
-

tachometer-reporter-action v2 for Benchmarks

@github-actions
Copy link
Contributor

github-actions bot commented Feb 19, 2025

The size of lit-html.js and lit-core.min.js are as expected.

@sorvell sorvell changed the title Adds skipInitial property option Adds useDefault property option Feb 20, 2025
@sorvell sorvell marked this pull request as ready for review February 24, 2025 17:51
Copy link
Collaborator

@justinfagnani justinfagnani left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉 awesome!!

@sorvell sorvell merged commit 4824c4c into main Mar 8, 2025
8 of 9 checks passed
@sorvell sorvell deleted the skip-initial branch March 8, 2025 11:40
@freshp86
Copy link

freshp86 commented Mar 12, 2025

We are encountering an issue in the Chromium codebase as part of migrating to using the accessor keyword (to remove our dependency to useDefineForClassFields:false) along with reflect: true, which sounds that this new useDefault: true option might solve?

Please see https://issues.chromium.org/issues/389737066#comment12 for more context or directly see this minimal Lit playground repro

Note: This is an alternative to #4895, only one should be merged.

Completely ignoring the initial value would probably not work for us, as we are already getting failed tests when migrating to the accessor keyword due to this behavior.

Having said that, if this option indeed solves our issue (tried it on the minimal repro in the playground, but didn't, perhaps not rolled out yet?), we would still need to add useDefault: true everywhere where we use reflect: true which sounds quite cumbersome.

@justinfagnani
Copy link
Collaborator

@freshp86 can you open an issue?

It seems like the issues is that with experimental decorators and auto-accessors, the initial value isn't reflected? I'll have to check our tests for that case.

@sorvell on the linked playground, adding wrapped: true fixes the issue. Maybe this is a case of decorating the getter vs the setter.

@sorvell
Copy link
Member Author

sorvell commented Mar 12, 2025

can you open an issue?

No issue is needed. This change corrects this behavior.

@freshp86 This PR should also fix your issue, regardless of using useDefault. We were only properly handling accessor when using the @property decorator, not static properties. This was corrected here via this PR.

As @justinfagnani mentioned, while waiting for this release, you can add wrapped: true to the property options to correct the behavior. This workaround will be unnecessary in the next Lit release, but it is compatible with it (repro with workaround)

@freshp86
Copy link

freshp86 commented Mar 12, 2025

As @justinfagnani mentioned, while waiting for this release, you can add wrapped: true to the property options to correct the behavior.

Thanks for the quick response! I verified that adding wrapped: true fixes both the Lit playground repro, as well as one of the tests that were failing in Chromium (will verify remaining tests shortly). I don't see the wrapped option documented at https://lit.dev/docs/components/properties/#property-options though, so curious if this is considered part of the public API.

This workaround will be unnecessary in the next Lit release, but it is compatible with it (repro with workaround)

Do you have any estimate on when the next release will come out? In Chromium we still use v3.0.2 here, which is already behind latest 3.2.1, so we'll need to roll forward a few versions to get this fix.

In the meantime, do you think locally patching these lines that you referernced in our Chromium copy of Lit would be sufficient to fix the issue, without us having to update to later releases (and without having to add wrapped: true)?

@sorvell
Copy link
Member Author

sorvell commented Mar 12, 2025

In the meantime

I think it's probably simpler to use the undocumented wrapped: true rather than patching, and just update when we publish the next version. We can probably do a release this week.

XianHain pushed a commit to LeaseQuery/lit that referenced this pull request Mar 12, 2025
* Adds skipping initial attribute reflection for default values.
@freshp86
Copy link

Might have found an even easier fix for us, which does not require updating client code with wrapped: true, by automatically adding it during our static override finalize() {...} method for the CrLitElement class (which all other Lit element's extend from). See https://chromium-review.googlesource.com/c/chromium/src/+/6349561. I have verified this fixes previously failing tests, but still waiting to confirm that no other tests break because of it.

@sorvell
Copy link
Member Author

sorvell commented Mar 12, 2025

Might have found an even easier fix for us, which does not require updating client code with wrapped: true

If you have a base class, probably the simplest way would be:

static createProperty(name, options) {
  super.createProperty(name, {...options, wrapped: true});
}

@freshp86
Copy link

freshp86 commented Mar 12, 2025

If you have a base class, probably the simplest way would be:

Don't we need to check for if (this.prototype.hasOwnProperty(name)) {...} similar to your PR to only add the wrapped: true for such cases? In the superclass we still need to deal with both cases (using/not-using accessor). Also, don't we only need to add this for cases where reflect: true instead of adding it to all reactive porperties?

@sorvell
Copy link
Member Author

sorvell commented Mar 12, 2025

Don't we need to check for if (this.prototype.hasOwnProperty(name)) {...}

It's only strictly needed there, but you don't need to discriminate it. Setting wrapped forces the property to be in the changed properties if it has a value on first update. Valued non-own properties are already in changed properties.

Also, don't we only need to add this for cases where reflect: true instead of adding it to all reactive porperties?

No, it should not be based on the value of reflect. As noted, setting wrapped ensures the property is in the changed properties. This makes reflection work and also ensures the property is included in changed properties (e.g. updated(changed /* in this map */) )

@freshp86
Copy link

Thanks for the info! I will update my CL and tests at https://chromium-review.googlesource.com/c/chromium/src/+/6349561 and I might add you as a reviewer as well if you don't mind. Thanks!

@freshp86
Copy link

freshp86 commented Mar 13, 2025

As noted, setting wrapped ensures the property is in the changed properties...

So, while writing tests to test changed properties as well, discovered that there is still a difference in behavior between non-accessor and acessor properties on first load. Minimal repro here. In short accessor properties while indeed present in changed properties when wrapped: true is used, their "previous" value reported is not undefined, which IIUC it should, see snippet below. (Perhaps this is also fixed as part of this PR?).

willUpdate(changedProperties: PropertyValues<this>) {
  super.willUpdate(changedProperties);
  // expecting 'undefined', but instead initial value is reported as the "previous" value.
  console.log(changedProperties.get('someAccessorProperty')); 
}

@sorvell
Copy link
Member Author

sorvell commented Mar 13, 2025

In short accessor properties while indeed present in changed properties when wrapped: true is used, their "previous" value reported is not undefined

Yeah, I should have mentioned that. This is also fixed in this change, and you can patch it in if you need, but it's also technically safe to do in your base class like this (since _$* properties are not renamed):

fixed repro with code below

override protected performUpdate() {
  if (!this.isUpdatePending) {
    return;
  }
  // this loop is duplicated in the super call but the work won't occur twice due to the 
  // `_$changedProperties` check
  if (!this.hasUpdated) {
    const {elementProperties} = (this.constructor as typeof ReactiveElement);
    for (const [p, options] of elementProperties) {
      if (options.wrapped === true && !this._$changedProperties.has(p) &&
          this[p as keyof this] !== undefined) {
        this._$changeProperty(p, undefined, options);
      }
    }
  }
  super.performUpdate();
}

@freshp86
Copy link

freshp86 commented Mar 13, 2025

Thanks for the detailed response, this is indeed very helpful and does fix the issue!

Having said that, fixing the issue in our CrLitElement base is a bit complicated. Specifically, after verifying that the above works in the minimal repro, plugging it in CrLitELemnet didn't initially work, and took quite a bit to debug. The problem is that we operate on the minified version of ReactiveElement from node_modules/@lit/reactive-element/reactive-element.js, where the internal APIs _$changedProperties and _$changeProperty are minified to _$AL and C respectively (took a while to figure out why _$changedProperties was undefined). We also need to make TypeScript happy while using those internal APIs, so the end result looks as follows:

  override performUpdate() {
    if (!this.isUpdatePending) {
      return;
    }
    if (!this.hasUpdated) {
      interface WithInternalApis extends CrLitElement {
        // minified name for $changedProperties
        _$AL: PropertyValues;
        // minified name for _$changeProperty()
        C(p: PropertyKey, v: any, options: any): void;
      }

      const {elementProperties} = this.constructor as typeof ReactiveElement;
      for (const [p, options] of elementProperties) {
        if ((options as {wrapped?: boolean}).wrapped === true &&
            !(this as unknown as WithInternalApis)._$AL.has(p) &&
            this[p as keyof this] !== undefined) {
          (this as unknown as WithInternalApis).C(p, undefined, options);
        }
      }
    }
    super.performUpdate();
  }

I think this qualifies as a hack :> You can see the complete change at https://chromium-review.googlesource.com/c/chromium/src/+/6349561/7/third_party/lit/v3_0/cr_lit_element.ts.

So, the sooner we can get the official fix the sooner we can remove this hack. Rolling our Lit version to the latest Lit version is also blocked by #4943, so the current plan is

  1. Land the hacky fixes directly in CrLitElement, to unblock the migration to the accessor keyword (https://issues.chromium.org/issues/389737066).
  2. Roll our Lit version to 3.2.1 as soon as there is a fix for [ts5.8.2+node16] error TS2307: Cannot find module 'trusted-types/lib' or its corresponding type declarations #4943 (we would like to avoid adding skipLibCheck across the entire repo), https://issues.chromium.org/issues/402800778.
  3. Roll to 3.2.2 (or whichever version will have these fixes) and remove the workarounds in CrLitElement.

Thanks again!

aarongable pushed a commit to chromium/chromium that referenced this pull request Mar 14, 2025
This is necessary to workaround a bug when the 'accessor' keyword is
used where Lit doesn't reflect the initial value when 'reflect: true' is
used, nor does it populate `changedProperties` correctly (see minimal
repro at [1] and [2] respectively). The bug has been recently fixed in
Lit at [3] but is not available in any Lit release yet. These
workarounds here were mentioned in the discussion at [3].

Workaround#1: Add the undocumented `wrapped: true` property option for
properties that are using the 'accessor' keyword.

Workaround#2: Modify `changedProperties` previous value to be
`undefined` to match the behavior of normal (non-wrapped) properties.

The plan is to remove these workarounds when the fixes are available
in an upcoming Lit release.

[1] http://shortn/_FCrUJIGsR7
[2] http://shortn/_5l2Q3hBfr0
[3] lit/lit#4934

Bug: 389737066
Change-Id: Ib032ce41c9c1fa9647a0ec7c9e629a869cc3892d
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6349561
Reviewed-by: Rebekah Potter <rbpotter@chromium.org>
Commit-Queue: Demetrios Papadopoulos <dpapad@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1432900}
@freshp86
Copy link

freshp86 commented Mar 19, 2025

@sorvell Unfortunately discovered one more difference in changed properties behavior between wrapped and non-wrapped properties, when a value is passed from the parent, see new minimal repro here, where the 'previous' value of the 1st willUpdate() and updated() callbacks is not undefined (which was revealed by some of our Chromium tests failing when we deploy the 'accessor' keyword + our workaround more widely, which are failing this assertion).

Note that the problem happens even if the && !this._$changedProperties.has(p) part is removed from the following condition

if (options.wrapped === true && !this._$changedProperties.has(p) && this[p] !== undefined) {    
  this._$changeProperty(p, undefined, options);
}

and the this._$changeProperty(...) call seems to have no effect basically.

Do you know if the fix in this PR addresses this, or if this is just a problem with the temporary workaround we discussed in earlier comments. Is there a way to make these cases behave identically across wrapped and non-wrapped properties?

Update: Possible refinement to our workaround is to directly call this._$changeProperties.set() when the property is already a member, instead of calling this._$changeProperty(...) , which seems to ignore the oldValue passed to it in such cases in this line, see possible CrLitElement diff here, not sure if this is 100% safe to do because it skips the part in _$changeProperty() that updates __reflectingProperties, but it does seem to fix the issue locally.

Perhaps calling both as follows is necessary?

        this._$changeProperty(p, undefined, options);
        this._$changedProperties.set(p, undefined);

Ultimately I think we need a way to skip the if check at this line https://github.com/lit/lit/blob/main/packages/reactive-element/src/reactive-element.ts#L1338.

@sorvell
Copy link
Member Author

sorvell commented Mar 20, 2025

Do you know if the fix in this PR addresses this

No, see #4916 where I linked your comment. Maybe best to continue, if needed, there.

@lit-robot lit-robot mentioned this pull request Apr 10, 2025
rictic added a commit that referenced this pull request Apr 14, 2025
Fixes a regression from #4934 which collapsed all converted nullish values to null.
rictic added a commit that referenced this pull request May 5, 2025
* fromAttribute can set the property to either null or undefined.

Fixes a regression from #4934 which collapsed all converted nullish values to null.

* Update .changeset/many-grapes-rest.md

Co-authored-by: Augustine Kim <augustjkim@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants