@@ -10,8 +10,7 @@ const { isSSREscape } = require('./util/ssr');
1010const { isGlobalIdentifier } = require ( './util/scope' ) ;
1111
1212/**
13- * Visitors for detecting methods/functions that are reacheable during SSR
14- * @param {import('eslint').Rule.RuleContext } context
13+ * Visitors for detecting methods/functions that are reachable during SSR
1514 */
1615function reachableDuringSSRPartial ( ) {
1716 let moduleInfo ;
@@ -117,6 +116,14 @@ const moduleScopeDisqualifiers = new Set([
117116 'ArrowFunctionExpression' ,
118117] ) ;
119118
119+ const noReferenceParentQualifiers = new Set ( [
120+ 'CallExpression' ,
121+ 'ExpressionStatement' ,
122+ 'AssignmentExpression' ,
123+ ] ) ;
124+
125+ const globalAccessQualifiers = new Set ( [ 'CallExpression' , 'MemberExpression' ] ) ;
126+
120127function inModuleScope ( node , context ) {
121128 for ( const ancestor of context . getAncestors ( ) ) {
122129 if ( moduleScopeDisqualifiers . has ( ancestor . type ) ) {
@@ -149,13 +156,14 @@ module.exports.noReferenceDuringSSR = function noReferenceDuringSSR(
149156 ) {
150157 return ;
151158 }
159+
152160 if (
153161 node . parent . type === 'MemberExpression' &&
162+ node . parent . optional !== true &&
154163 node . object . type === 'Identifier' &&
155164 node . object . name === 'globalThis' &&
156165 node . property . type === 'Identifier' &&
157166 forbiddenGlobalNames . has ( node . property . name ) &&
158- node . parent . optional !== true &&
159167 isGlobalIdentifier ( node . object , context . getScope ( ) )
160168 ) {
161169 // Prevents expressions like:
@@ -207,7 +215,7 @@ module.exports.noReferenceDuringSSR = function noReferenceDuringSSR(
207215 return ;
208216 }
209217 if (
210- node . parent . type !== 'MemberExpression' &&
218+ noReferenceParentQualifiers . has ( node . parent . type ) &&
211219 forbiddenGlobalNames . has ( node . name ) &&
212220 isGlobalIdentifier ( node , context . getScope ( ) )
213221 ) {
@@ -229,25 +237,82 @@ module.exports.noReferenceDuringSSR = function noReferenceDuringSSR(
229237 } ;
230238} ;
231239
240+ /**
241+ * Reports issues about accessing unsupported LWC class methods in SSR.
242+ * @see {@link https://github.com/salesforce/lwc/blob/master/packages/%40lwc/engine-server/src/renderer.ts }
243+ */
232244module . exports . noPropertyAccessDuringSSR = function noPropertyAccessDuringSSR (
233245 forbiddenPropertyNames ,
234246 reporter ,
235247) {
236248 const { withinLWCVisitors, isInsideReachableMethod, isInsideSkippedBlock } =
237249 reachableDuringSSRPartial ( ) ;
250+ let expressionStatementWeAreIn = null ;
251+ let memberExpressionsInStatement = [ ] ;
252+ let callExpressionsInStatement = [ ] ;
238253
239254 return {
240255 ...withinLWCVisitors ,
256+ ExpressionStatement : ( node ) => {
257+ expressionStatementWeAreIn = node ;
258+ callExpressionsInStatement = [ ] ;
259+ memberExpressionsInStatement = [ ] ;
260+ } ,
261+ 'ExpressionStatement:exit' : ( node ) => {
262+ if ( expressionStatementWeAreIn === node ) {
263+ expressionStatementWeAreIn = null ;
264+ callExpressionsInStatement = [ ] ;
265+ memberExpressionsInStatement = [ ] ;
266+ }
267+ } ,
268+ AssignmentExpression : ( node ) => {
269+ callExpressionsInStatement . push ( node ) ;
270+ } ,
271+ CallExpression : ( node ) => {
272+ callExpressionsInStatement . push ( node ) ;
273+ } ,
241274 MemberExpression : ( node ) => {
242275 if ( ! isInsideReachableMethod ( ) || isInsideSkippedBlock ( ) ) {
243276 return ;
244277 }
278+
279+ memberExpressionsInStatement . push ( node ) ;
280+
245281 if (
246282 node . object . type === 'ThisExpression' &&
283+ globalAccessQualifiers . has ( node . parent . type ) &&
247284 node . property . type === 'Identifier' &&
248285 forbiddenPropertyNames . includes ( node . property . name )
249286 ) {
250- reporter ( node ) ;
287+ // Prevents expressions like:
288+ // this.dispatchEvent(new CustomEvent('myevent'));
289+ // this.querySelector('button').addEventListener('click', ...);
290+ // this.querySelector?.('button').addEventListener('click', ...);
291+ // this.querySelector?.('button')?.addEventListener('click', ...);
292+ // this.querySelector?.('button').firstElementChild.id;
293+ // this.childNodes.item(0).textContent = 'foo';
294+
295+ // Allows all-optional expressions like:
296+ // this.dispatchEvent?.(new CustomEvent('myevent'));
297+ // this.querySelector?.('button')?.addEventListener?.('click', ...);
298+ // this.querySelector?.('button')?.firstElementChild.id;
299+ const allCallExpressionsOptional = callExpressionsInStatement . every (
300+ ( expression ) => expression . optional ,
301+ ) ;
302+ const allMemberExpressionsOptional = memberExpressionsInStatement . every (
303+ ( expression , index ) => {
304+ if ( expression . parent && expression . parent . type === 'CallExpression' ) {
305+ // Skip CallExpressions here as they are treated separately
306+ return true ;
307+ }
308+ // Return `true` if the MemberExpression is either `optional` or
309+ // the last expression of the chain (which is in revered order).
310+ return expression . optional || index === 0 ;
311+ } ,
312+ ) ;
313+ if ( ! allCallExpressionsOptional || ! allMemberExpressionsOptional ) {
314+ reporter ( node ) ;
315+ }
251316 }
252317 } ,
253318 } ;
0 commit comments