Skip to content

Commit bbd1d95

Browse files
authored
feat: enforce import.meta guard due to LWC compiler behavior @W-14387292 (#134)
1 parent fd29cd6 commit bbd1d95

File tree

7 files changed

+234
-105
lines changed

7 files changed

+234
-105
lines changed

docs/rules/no-unsupported-ssr-properties.md

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { LightningElement } from 'lwc';
1313

1414
export default class Foo extends LightningElement {
1515
connectedCallback() {
16-
this.querySelector('span')?.foo();
16+
this.querySelector('span')?.getAttribute?.('role');
1717
}
1818
}
1919

@@ -29,22 +29,6 @@ Examples of **correct** code for this rule:
2929
```js
3030
import { LightningElement } from 'lwc';
3131

32-
export default class Foo extends LightningElement {
33-
connectedCallback() {
34-
this.querySelector?.('span')?.getAttribute?.('role');
35-
}
36-
}
37-
38-
export default class Foo extends LightningElement {
39-
connectedCallback() {
40-
this.dispatchEvent?.(new CustomEvent('customevent'));
41-
}
42-
}
43-
```
44-
45-
```js
46-
import { LightningElement } from 'lwc';
47-
4832
export default class Foo extends LightningElement {
4933
connectedCallback() {
5034
if (!import.meta.env.SSR) {
@@ -73,7 +57,7 @@ export default class Foo extends LightningElement {
7357

7458
export default class Foo extends LightningElement {
7559
renderedCallback() {
76-
// Caution: This lifecycle hook is very likely
60+
// **Caution:** This lifecycle hook is very likely
7761
// to be called more than once.
7862
this.dispatchEvent(new CustomEvent('customevent'));
7963
}

lib/analyze-component.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,19 @@ function getClassDeclaration(root) {
8484
function getMethodInterdependencies(classDecl) {
8585
const dependencies = new Map();
8686

87-
const methodNames = classDecl.body.body
87+
const { methodNames, getters } = classDecl.body.body
8888
.filter((node) => node.type === 'MethodDefinition' && node.key.type === 'Identifier')
89-
.map((methodDefNode) => methodDefNode.key.name);
89+
.reduce(
90+
(acc, methodDefNode) => {
91+
const { name } = methodDefNode.key;
92+
acc.methodNames.push(name);
93+
if (methodDefNode.kind === 'get') {
94+
acc.getters.push(name);
95+
}
96+
return acc;
97+
},
98+
{ methodNames: [], getters: [] },
99+
);
90100

91101
for (const methodName of methodNames) {
92102
dependencies.set(methodName, []);
@@ -114,11 +124,12 @@ function getMethodInterdependencies(classDecl) {
114124
});
115125
}
116126

117-
return dependencies;
127+
return [dependencies, getters];
118128
}
119129

120130
function getMethodsReachableDuringSSR(
121131
methodInterdependencies,
132+
getters = [],
122133
fromMethods = ['connectedCallback', 'constructor', 'render'],
123134
) {
124135
const reachableMethods = new Set();
@@ -142,7 +153,7 @@ function getMethodsReachableDuringSSR(
142153
}
143154
};
144155

145-
for (const methodName of fromMethods) {
156+
for (const methodName of new Set([...fromMethods, ...getters])) {
146157
markAsReachable(methodName);
147158
}
148159

@@ -200,8 +211,8 @@ module.exports.analyze = function analyze(root) {
200211
let methodsReachableDuringSSR = new Set();
201212

202213
if (isLWC) {
203-
const methodInterdependencies = getMethodInterdependencies(lwcClassDeclaration);
204-
methodsReachableDuringSSR = getMethodsReachableDuringSSR(methodInterdependencies);
214+
const [methodInterdependencies, getters] = getMethodInterdependencies(lwcClassDeclaration);
215+
methodsReachableDuringSSR = getMethodsReachableDuringSSR(methodInterdependencies, getters);
205216
moduleScopedFunctionsReachableDuringSSR = getFunctionsReachableDuringSSR(
206217
methodsReachableDuringSSR,
207218
moduleScopedFunctions,

lib/rule-helpers.js

Lines changed: 40 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ function reachableDuringSSRPartial() {
1818
let reachableFunctionThatWeAreIn = null;
1919
let reachableMethodThatWeAreIn = null;
2020
let skippedBlockThatWeAreIn = null;
21+
let skippedConditionThatWeAreIn = null;
2122

2223
const withinLWCVisitors = {
2324
Program: (node) => {
@@ -100,13 +101,23 @@ function reachableDuringSSRPartial() {
100101
skippedBlockThatWeAreIn = null;
101102
}
102103
},
104+
ConditionalExpression: (node) => {
105+
if (isSSREscape(node)) {
106+
skippedConditionThatWeAreIn = node;
107+
}
108+
},
109+
'ConditionalExpression:exit': (node) => {
110+
if (skippedConditionThatWeAreIn === node) {
111+
skippedConditionThatWeAreIn = null;
112+
}
113+
},
103114
};
104115

105116
return {
106117
withinLWCVisitors,
107118
isInsideReachableMethod: () => insideLWC && !!reachableMethodThatWeAreIn,
108119
isInsideReachableFunction: () => !!reachableFunctionThatWeAreIn,
109-
isInsideSkippedBlock: () => !!skippedBlockThatWeAreIn,
120+
isInsideSkippedBlock: () => !!skippedBlockThatWeAreIn || !!skippedConditionThatWeAreIn,
110121
};
111122
}
112123

@@ -159,18 +170,21 @@ module.exports.noReferenceDuringSSR = function noReferenceDuringSSR(
159170

160171
if (
161172
node.parent.type === 'MemberExpression' &&
162-
node.parent.optional !== true &&
163173
node.object.type === 'Identifier' &&
164-
node.object.name === 'globalThis' &&
174+
((node.object.name === 'globalThis' && node.parent.optional !== true) ||
175+
node.object.name === 'window') &&
165176
node.property.type === 'Identifier' &&
166177
forbiddenGlobalNames.has(node.property.name) &&
167178
isGlobalIdentifier(node.object, context.getScope())
168179
) {
169180
// Prevents expressions like:
170181
// globalThis.document.addEventListener('click', () => { ... });
182+
// const url = window.location.href;
183+
// const url = window.location?.href;
171184

172185
// Allows expressions like:
173186
// globalThis.document?.addEventListener('click', () => { ... });
187+
// const url = globalThis.location?.href;
174188
context.report({
175189
messageId: messageIds.at(1),
176190
node,
@@ -182,17 +196,33 @@ module.exports.noReferenceDuringSSR = function noReferenceDuringSSR(
182196
} else if (
183197
node.parent.type !== 'MemberExpression' &&
184198
node.object.type === 'Identifier' &&
185-
(forbiddenGlobalNames.has(node.object.name) ||
186-
(node.object.name === 'globalThis' && node.optional !== true)) &&
199+
forbiddenGlobalNames.has(node.object.name) &&
187200
isGlobalIdentifier(node.object, context.getScope())
188201
) {
189202
// Prevents expressions like:
190-
// globalThis.addEventListener('click', () => { ... });
191203
// document.addEventListener('click', () => { ... });
192204
// document?.addEventListener('click', () => { ... });
205+
context.report({
206+
messageId: messageIds.at(0),
207+
node,
208+
data: {
209+
identifier: node.object.name,
210+
},
211+
});
212+
} else if (
213+
node.parent.type === 'CallExpression' &&
214+
node.parent.optional !== true &&
215+
node.object.type === 'Identifier' &&
216+
node.object.name === 'globalThis' &&
217+
node.property.type === 'Identifier' &&
218+
forbiddenGlobalNames.has(node.property.name) &&
219+
isGlobalIdentifier(node.object, context.getScope())
220+
) {
221+
// Prevents expressions like:
222+
// globalThis.addEventListener('click', () => { ... });
193223

194224
// Allows expressions like:
195-
// globalThis?.addEventListener('click', () => { ... });
225+
// globalThis.addEventListener?.('click', () => { ... });
196226
context.report({
197227
messageId: messageIds.at(0),
198228
node,
@@ -247,37 +277,14 @@ module.exports.noPropertyAccessDuringSSR = function noPropertyAccessDuringSSR(
247277
) {
248278
const { withinLWCVisitors, isInsideReachableMethod, isInsideSkippedBlock } =
249279
reachableDuringSSRPartial();
250-
let expressionStatementWeAreIn = null;
251-
let memberExpressionsInStatement = [];
252-
let callExpressionsInStatement = [];
253280

254281
return {
255282
...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-
},
274283
MemberExpression: (node) => {
275284
if (!isInsideReachableMethod() || isInsideSkippedBlock()) {
276285
return;
277286
}
278287

279-
memberExpressionsInStatement.push(node);
280-
281288
if (
282289
node.object.type === 'ThisExpression' &&
283290
globalAccessQualifiers.has(node.parent.type) &&
@@ -289,30 +296,11 @@ module.exports.noPropertyAccessDuringSSR = function noPropertyAccessDuringSSR(
289296
// this.querySelector('button').addEventListener('click', ...);
290297
// this.querySelector?.('button').addEventListener('click', ...);
291298
// this.querySelector?.('button')?.addEventListener('click', ...);
299+
// this.querySelector?.('button')?.addEventListener?.('click', ...);
292300
// this.querySelector?.('button').firstElementChild.id;
301+
// this.querySelector?.('button').firstElementChild?.id;
293302
// 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-
}
303+
reporter(node);
316304
}
317305
},
318306
};

lib/rules/no-unsupported-ssr-properties.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ module.exports = {
3535
schema: [],
3636
messages: {
3737
propertyAccessFound:
38-
'`{{ identifier }}` is unsupported in SSR. Consider guarding access to `{{identifier}}`, e.g. via the `import.meta.env.SSR` flag, or optional chaining (`this.{{identifier}}?.`).',
38+
'`{{ identifier }}` is unsupported in SSR. Consider guarding access to `{{identifier}}` via `import.meta.env.SSR`.',
3939
},
4040
},
4141
create: (context) => {

lib/util/ssr.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
module.exports.isSSREscape = function isSSREscape(node) {
1010
return (
11-
node.type === 'IfStatement' &&
11+
(node.type === 'IfStatement' || node.type === 'ConditionalExpression') &&
1212
(isMetaEnvCheck(node.test) || isWindowOrDocumentCheck(node.test))
1313
);
1414
};

0 commit comments

Comments
 (0)