Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make @query not cache null results before first render (#4237) #4282

Merged
merged 12 commits into from
Nov 9, 2023
5 changes: 5 additions & 0 deletions .changeset/quick-moons-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lit/reactive-element': patch
---
MaxArt2501 marked this conversation as resolved.
Show resolved Hide resolved

Fix a bug where accessing a `@query` decorated field with the `cache` flag set before the first update would result in `null` being cached permanently. `null` will no longer be cached before the first update and in `DEV_MODE` now raises a warning.
44 changes: 38 additions & 6 deletions packages/reactive-element/src/decorators/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,26 @@ import {desc, type Interface} from './base.js';

const DEV_MODE = true;

let issueWarning: (code: string, warning: string) => void;

if (DEV_MODE) {
// Ensure warnings are issued only 1x, even if multiple versions of Lit
// are loaded.
const issuedWarnings: Set<string | undefined> =
(globalThis.litIssuedWarnings ??= new Set());

// Issue a warning, if we haven't already.
issueWarning = (code: string, warning: string) => {
warning += code
? ` See https://lit.dev/msg/${code} for more information.`
: '';
if (!issuedWarnings.has(warning)) {
console.warn(warning);
issuedWarnings.add(warning);
}
};
}

export type QueryDecorator = {
// legacy
(
Expand Down Expand Up @@ -64,6 +84,19 @@ export function query(selector: string, cache?: boolean): QueryDecorator {
descriptor?: PropertyDescriptor
) => {
const doQuery = (el: Interface<ReactiveElement>): V => {
if (DEV_MODE && !el.hasUpdated) {
Copy link
Contributor

Choose a reason for hiding this comment

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

To resolve the test failure, and to be more specific for the caching null case, I propose adding an && cache here.

Copy link
Member

Choose a reason for hiding this comment

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

Interesting. The warning itself is generic that the getter was called before first update, and not specific to the cache option.

I wonder if the failing task test should be updated instead to exclude this warning instead of opting out this warning. (also interesting to see that the task test does highlight one way the query decorated property might get accessed before first update).

Copy link
Member

Choose a reason for hiding this comment

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

I could also be convinced to just only warn when cache is true too. It's more impactful to raise attention if null is getting cached where as the warning for none-caching scenarios might just be excess noise.

Copy link
Contributor

Choose a reason for hiding this comment

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

Given that a test has shown a valid reason to access a @query decorated field before the first update (in a Task dependency array). I wouldn't want to silence the warning in only that test case, because we may end up having users that get the warning via a valid usage of Task depending on a @query field.

I think the warning makes sense if there is something bad going on, but the fix to cache behavior resolves the worst possible bugs. I propose removing the warnings from this PR.

Copy link
Member

Choose a reason for hiding this comment

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

I agree, it seems fine to not warn when there's a legit case to encounter it. @rictic originally suggested in the issue and PR to add a dev mode warning, what do you think about removing it?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There are some combinations I've considered for the warning. We could issue it if cache is true, but also when the query result is actually null. Would that be acceptable?

Copy link
Member

Choose a reason for hiding this comment

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

Caching null sounds like an unintended behavior so warning in that case sounds reasonable to me.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes please. Making the warning specific to: cache: true and result is null, is a perfect time for a warning. It's much more specific to the issue you are fixing.
Thanks!

const name =
typeof nameOrContext === 'object'
? nameOrContext.name
: nameOrContext;
issueWarning(
'',
`@query'd field ${JSON.stringify(String(name))} for selector ` +
`'${selector}' has been accessed before the first update. This ` +
`yields a certain null result if the renderRoot tree has not ` +
`been provided beforehand (e.g. via Declarative Shadow DOM).`
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for bearing with these changed, I think the last change required is to update the warning to relate to the situation that was handled. E.g., You are preventing the caching of a null value.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was just wondering if the message should closely reflect the conditions for issuing the warning 👍
I've kept a brief explanation of the cause since there isn't a detailed explanation in the documentation yet. I think it could be removed later to keep the message concise.

);
}
// TODO: if we want to allow users to assert that the query will never
// return null, we need a new option and to throw here if the result
// is null.
Expand Down Expand Up @@ -98,15 +131,14 @@ export function query(selector: string, cache?: boolean): QueryDecorator {
})();
return desc(protoOrTarget, nameOrContext, {
get(this: ReactiveElement): V {
if (cache) {
let result: V = get!.call(this);
if (result === undefined) {
result = doQuery(this);
let result: V = get!.call(this);
if (result === undefined) {
result = doQuery(this);
if (result !== null || this.hasUpdated) {
set!.call(this, result);
}
return result;
}
return doQuery(this);
return result;
},
});
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,27 @@ import {assert} from '@esm-bundle/chai';
assert.notEqual(el.span, el.renderRoot.querySelector('span'));
});

test('returns cached value when accessed before first update', async () => {
test('does not cache null values when accessed before first update', async () => {
const notYetUpdatedEl = new C();
assert.equal(notYetUpdatedEl.divCached, null);

if (globalThis.litIssuedWarnings != null) {
assert(
[...globalThis.litIssuedWarnings].find((w) =>
/@query'd field "divCached" for selector '#blah' has been accessed before the first update\. This yields a certain null result if the renderRoot tree has not been provided beforehand \(e\.g\. via Declarative Shadow DOM\)\./.test(
w ?? ''
)
),
`Expected warning to be issued. Warnings found: ${JSON.stringify(
[...globalThis.litIssuedWarnings],
null,
2
)}`
);
}

container.appendChild(notYetUpdatedEl);
AndrewJakubowicz marked this conversation as resolved.
Show resolved Hide resolved
await notYetUpdatedEl.updateComplete;
assert.equal(notYetUpdatedEl.divCached, null);
assert.instanceOf(notYetUpdatedEl.divCached, HTMLDivElement);
});
});
20 changes: 18 additions & 2 deletions packages/reactive-element/src/test/decorators/query_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,28 @@ import {assert} from '@esm-bundle/chai';
assert.notEqual(el.span, el.renderRoot.querySelector('span'));
});

test('returns cached value when accessed before first update', async () => {
test('does not cache null values when accessed before first update', async () => {
const notYetUpdatedEl = new C();
assert.equal(notYetUpdatedEl.divCached, null);

if (globalThis.litIssuedWarnings != null) {
assert(
[...globalThis.litIssuedWarnings].find((w) =>
/@query'd field "divCached" for selector '#blah' has been accessed before the first update\. This yields a certain null result if the renderRoot tree has not been provided beforehand \(e\.g\. via Declarative Shadow DOM\)\./.test(
w ?? ''
)
),
`Expected warning to be issued. Warnings found: ${JSON.stringify(
[...globalThis.litIssuedWarnings],
null,
2
)}`
);
}

container.appendChild(notYetUpdatedEl);
await notYetUpdatedEl.updateComplete;
assert.equal(notYetUpdatedEl.divCached, null);
assert.instanceOf(notYetUpdatedEl.divCached, HTMLDivElement);
});

test('works with an old and busted Reflect.decorate', async () => {
Expand Down