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
feat: augments metadata gathering for @wire decorator #631
Conversation
For example `@wire(getRecord, recordId=userId)`, where recordIs is imported: `import userId from '@salesforce/user/id'`;
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor changes to handle missing imports
import { wire } from 'lwc'; | ||
import { getRecord } from 'recordDataService'; | ||
import userId from '@salesforce/user/Id'; | ||
export default class Test { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we should test relative import
import rel from './relative';
import rel2 from './other/relative2.js';
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would love to have other tests a well:
const userId = '00xxxxxxxxxxxxxx';
export default class Test {
@wire(getRecord, { recordId: userId })
recordData;
}
const data = {
userId: '00xxxxxxxxxxxxxx'
}
export default class Test {
@wire(getRecord, { recordId: data.userId })
recordData;
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
|
||
if (config) { | ||
wiredValue.static = getWiredStatic(config); | ||
wiredValue.params = getWiredParams(t, config); | ||
} | ||
|
||
const getReferenceByName = name => path.scope.getBinding(name).path.parentPath.node.source.value; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we should add existence check to the output of getBinding(name) , as the import may not exist
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
path.scope.getBinding
is always present, the existence check is not needed.
However the traversal: parentPath.node.source.value
look really dangerous, we should be more defensive there. Here are couple of examples where this traversal would throw:
http://astexplorer.net/#/gist/0473ef83527151ddf39e4de17b9f7fab/63c21ccaf6616f56cb93a5a3ce6b3e1e979926ab
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looks like path.scope.getBinding is null when import is missing, fixed & added test, also added some defensive checks but I'm not sure whether that's needed for module imports since I added a restriction to only resolve module imports, I'll need to revisit this.
if (id.isIdentifier()) { | ||
wiredValue.adapter = { | ||
name: id.node.name, | ||
reference: path.scope.getBinding(id.node.name).path.parentPath.node.source.value | ||
reference: getReferenceByName(id.node.name) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we could preserve id.node.name reference since its accessed twice here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
properties.forEach(s => { | ||
if (s.key.type === 'Identifier' && s.value.type === 'ArrayExpression') { | ||
ret[s.key.name] = s.value.elements.map(e => e.value); | ||
} else if (s.key.type === 'Identifier' && s.value.type === 'Identifier') { | ||
ret[s.key.name] = {reference: getReferenceByName(s.value.name)}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we still assign the value if getReferenceByName returned undefined? (in the event of a missing import)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
as per conversation, return undefined explicitly, let me know if I misunderstood the conclusion, I can - not set it otherwise
|
||
if (config) { | ||
wiredValue.static = getWiredStatic(config); | ||
wiredValue.params = getWiredParams(t, config); | ||
} | ||
|
||
const getReferenceByName = name => path.scope.getBinding(name).path.parentPath.node.source.value; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
path.scope.getBinding
is always present, the existence check is not needed.
However the traversal: parentPath.node.source.value
look really dangerous, we should be more defensive there. Here are couple of examples where this traversal would throw:
http://astexplorer.net/#/gist/0473ef83527151ddf39e4de17b9f7fab/63c21ccaf6616f56cb93a5a3ce6b3e1e979926ab
import { wire } from 'lwc'; | ||
import { getRecord } from 'recordDataService'; | ||
import userId from '@salesforce/user/Id'; | ||
export default class Test { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would love to have other tests a well:
const userId = '00xxxxxxxxxxxxxx';
export default class Test {
@wire(getRecord, { recordId: userId })
recordData;
}
const data = {
userId: '00xxxxxxxxxxxxxx'
}
export default class Test {
@wire(getRecord, { recordId: data.userId })
recordData;
}
I'm fine with this concept, the only thing to keep clear is that this work for global value providers, and should not be considered for any contextual information since such information can't be imported. |
Benchmark resultsBase commit: |
Benchmark resultsBase commit: |
Benchmark resultsBase commit: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm concerned with how the wire service will evaluate { reference: '@salesforce/user/Id' }
when it's evaluating the config object to pass to the wire adapter. I need to debug through this with you to ensure all the dots align.
Edit:
I just realized this is for compiler metadata, not the component output itself, right? If so then my concern is greatly reduced and remains only on the comments I've left elsewhere.
` | ||
import { wire } from 'lwc'; | ||
import { getRecord } from 'recordDataService'; | ||
import { id as currentUserId } from '@salesforce/user/Id'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is aliasing a named export. All the @salesforce
scoped imports use only default exports to handle referential integrity.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@kevinv11n do you proposed this reference to be collected as 'undefined' or not collected at all?
}, | ||
); | ||
pluginTest( | ||
'sets reference to undefined for const, let references', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why undefined for const with a static value?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the use case was deemed as unsupported for now (to my understanding)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Few minor changes.
- We briefly spoke about types today when attempting to resolve a complex objects and I wanted to bring up a discussion about the shape of the property value we return. The existing shape of the collected values varies depending on the type of the value we are resolving. For example, we will either return a string or an object depending on the property type. If it is a static 'const' we return a string, however, if it is a module import, we return an object with a reference key. ex:
static: { recordId: { reference: '@salesforce/user/Id' } }
or
static: { recordId: '$recordId' }
I propose to normalize the shape of the value and always return a type and a value. This will allow us to add more details about what exactly has or has not been resolved. For example:
static string:
static: { recordId: {
type: 'string',
value: '005000000000000000'
} }
import from module:
static: { recordId: {
type: 'module',
value: '@salesforce/userId/id'
} }
import from relative path:
static: { recordId: {
type: module,
value: undefined
} }
reference to an object:
params: { recordId: {
type: 'object',
value: undefined
} }
let reference
static: { recordId: {
type: undefined,
value: undfeined
} }
We could go even further and add something that @pmdartus has proposed, adding 'unresolved' type. This special type can be used to differentiate between undefined values and a failure to resolve a value or a module.
This is discussion is up for debate, but i wanted to get your thoughts on the suggestion.
packages/babel-plugin-transform-lwc-class/src/__tests__/wire-decorator.spec.js
Outdated
Show resolved
Hide resolved
adapter: { name: 'getRecord', reference: 'recordDataService' }, | ||
name: 'recordData', | ||
params: {}, | ||
static: {}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we include the 'dotParameter' and set its value as undefined?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
that's what I was asking yesterday, I can add that support but it means adding extra explicit checks for a new value type. I'm ok with it either way, if it's not immediately needed we can always add it later, it's not hard either way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i say if we decided to spend more time on this PR ( enhancing the value shape ), then it may be worth doing this case as well, otherwise let's move on.
` | ||
import { wire } from 'lwc'; | ||
import { getRecord } from 'recordDataService'; | ||
const userId = '005000000000000000'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe we can support static const? the value of 'let' can be changed, therefore we disallow its use, but the 'const' prevents one from doing that. Although, according to the quip doc, we do not resolve any complex types, therefore the 'const' support can only be done for strings.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
to be clear you want us to:
- support
const foo = '123';
@wire(.. { foo: foo })
- NOT support
const foo = '123;
const bar = foo;
@wire(... {bar: bar })
Should I do that as part of this story or another ? (the original story was about module imports, but I can tag it to this one).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, there is a way to walk that up to the original binding, but I'm find with supporting number 1 anyways.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You are right @sfdciuie , It's probably an overkill to keep walking up to resolve the value. We could attempt to do a first level resolution, but not sure if it will worth the effort right now. We can just leave it off as you originally had it then.
@kevinv11n yes, this is just the metadata. |
wireParameters: ['recordId: data.userId'], | ||
expectedStaticParameters: { recordId: { type: 'object' } } }); | ||
|
||
wireMetadataParameterTest('when an inline initialisation is used, should use value', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oddly enough, when I added this test it failed, so the metadata wasn't generated for it, had to add code to support it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: we use american spelling so it should be initialization
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there support for types other than module, string
packages/babel-plugin-transform-lwc-class/src/__tests__/wire-decorator.spec.js
Outdated
Show resolved
Hide resolved
{ wireParameters: ['recordId: id'], | ||
expectedStaticParameters: { recordId: {} } }); | ||
|
||
wireMetadataParameterTest('when importing with "as" from a module should reference the name of the module', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this is testing as
with a named export. can you add a test with as on a default export?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
import foo as bar from ..
is afaik invalid syntax (since foo is already an alias) ? I just tried and indeed it's flagged as syntax error.
just in case I added a few more cases, so we are testing:
import foo from
import {foo as bar} from
import { foo } from
packages/babel-plugin-transform-lwc-class/src/__tests__/wire-decorator.spec.js
Outdated
Show resolved
Hide resolved
packages/babel-plugin-transform-lwc-class/src/__tests__/wire-decorator.spec.js
Outdated
Show resolved
Hide resolved
wireParameters: ['recordId: data.userId'], | ||
expectedStaticParameters: { recordId: { type: 'object' } } }); | ||
|
||
wireMetadataParameterTest('when an inline initialisation is used, should use value', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: we use american spelling so it should be initialization
packages/babel-plugin-transform-lwc-class/src/__tests__/wire-decorator.spec.js
Show resolved
Hide resolved
wireMetadataParameterTest('when inline array with a non-string literal is used, should have "undefined" for non-string literal values', | ||
{ declaration: `const bar = '123';`, | ||
wireParameters: ['fields: ["foo", bar]'], | ||
expectedStaticParameters: { fields: {type: 'array', value: ['foo', undefined]} } }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i wonder if it's good to do this or treat the whole thing as unresolvable
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was pondering about that, it seems somewhat useful as you'd at least be able to analyze metadata and then figure out that say due to just one parameter an expensive query wasn't pre-cached so action may be taken ?
Up to you though, if you feel that the whole thing should be undefined I can change that.
packages/babel-plugin-transform-lwc-class/src/decorators/wire/transform.js
Outdated
Show resolved
Hide resolved
result = {type: 'object', value: undefined}; | ||
} | ||
} else { | ||
result = {type: 'unknown', value: `${s.key.type}-${valueType}`}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@apapko @kevinv11n @pmdartus Let me know how you feel about this one, added a new type "unknown" with value containing the key type & value type so that if something pops up we know whether there was a gap or something that we don't plan to support.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you give examples where we would return unkown
? If it's to guard against cases that we haven't anticipated I would rather fail hard.
if (s.key.type === 'Identifier') { | ||
if (valueType === 'ArrayExpression') { | ||
// @wire(getRecord, { fields: ['Id', 'Name'] }) | ||
result = {type: 'array', value: s.value.elements.map(e => e.value)}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we make sure somewhere else the code that array literal items only contains string literals? If it's the case let's add a comment otherwise, we need to be more defensive when accessing the value
property.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
see my comment regarding these on the appropriate test, we don't do any extra checks, the references get resolved as "undefined", up to you guys whether you want it to work like this or you want to undefine the whole thing, there are pros and cons with each approach.
packages/babel-plugin-transform-lwc-class/src/decorators/wire/transform.js
Outdated
Show resolved
Hide resolved
packages/babel-plugin-transform-lwc-class/src/decorators/wire/transform.js
Outdated
Show resolved
Hide resolved
// const userId = '123'; | ||
// @wire(getRecord, { userId: userId }) | ||
const reference = getReferenceByName(s.value.name); | ||
result = {value: reference.value, type: reference.type}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's hardcode identifier
in type, the reference.type
is equal to Identifier
(with a capital I
) and will not be consistent with other types.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not sure I understand? the reference.type has nothing to do with babel we set it ourselves in getReferenceByName, it's either going to be 'module' or 'string'/'number'/'boolean' (or undefined)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh my bad.
result = {value: reference.value, type: reference.type}; | ||
} else if (valueType === 'MemberExpression') { | ||
// @wire(getRecord, { userId: recordData.Id }) | ||
result = {type: 'object', value: undefined}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would rather set the value to null
instead of undefined
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe the thinking behind this was: If we could resolve the value and we know what it is, then we present that value, if we can't resolve it then it's undefined. Setting it to null might erroneously flag the consuming team into thinking we resolved the expression and it resolved to null
eg.
const data = { foo: null; }
@wire (getRecord, { extraParameter: data.foo })
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
possibly the better solution here to resolve ambiguities such as that, is to instead make everything that we're not resolving type: 'unresolved' with value being essentially the type (as a hint?)
so it could look like for a member expression:
{type: 'unresolved', value: 'member_expression'}
for a reference (ie. Identifier/Identifier)
{type: 'unresolved', value: 'reference'}
the only downside is that value has a special meaning here, which is not symmetrical to the resolved cases
@apapko thoughts ?
result = {type: 'object', value: undefined}; | ||
} | ||
} else { | ||
result = {type: 'unknown', value: `${s.key.type}-${valueType}`}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you give examples where we would return unkown
? If it's to guard against cases that we haven't anticipated I would rather fail hard.
|
||
if (config) { | ||
wiredValue.static = getWiredStatic(config); | ||
wiredValue.params = getWiredParams(t, config); | ||
} | ||
|
||
const getReferenceByName = getScopedReferenceByName.bind(this, path.scope); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bind
usage is not intuitive. I would rather use currying:
const referenceLookup = scope => name => {
// logic
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was just going by the existing convention in this file, where everythin was function() {}, I can change that of course,
do you mean
const getScopedReferenceByName = scope => name => {
const binding = scope.getBinding(name);
on the outside, and then
const getReferenceByName = getScopedReferenceByName(path.scope);
inside the module.export ?
@@ -94,9 +127,39 @@ function getWiredParamMetadata(properties) { | |||
return ret; | |||
} | |||
|
|||
function getScopedReferenceByName(scope, name) { | |||
let binding = scope.getBinding(name); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make binding
const
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for adding such great test coverage!
packages/babel-plugin-transform-lwc-class/src/__tests__/wire-decorator.spec.js
Outdated
Show resolved
Hide resolved
packages/babel-plugin-transform-lwc-class/src/decorators/wire/transform.js
Show resolved
Hide resolved
@apapko @kevinv11n @pmdartus Added a final change to mark arrays with unsupported literals as unresolved, and 2 more tests for the cases where one element is unresolved and for the case where all elements are resolved. |
Details
If a parameter of a @wire decorator contains an imported reference, this adds support for tracking of such metadata.
For example
@wire(getRecord, { recordId: id })
, where userIdis imported:
import id from '@salesforce/user/id'
;static 1st order references to a literal constant are now resolved, e.g.
In addition to strings, adds support for booleans and numbers
If a parameter is unresolved, adds it to metadata with type 'unresolved'
Adds type to resolved references/expressions
If an array contains an unresolved identifier/expression, marks the array as unresolved
Updates to test suite and various bug fixes
Does this PR introduce a breaking change?