Skip to content

Commit

Permalink
feat: update rule no-restricted-browser-globals-during-ssr to refle…
Browse files Browse the repository at this point in the history
…ct best practices @W-14336890
  • Loading branch information
seckardt committed Oct 20, 2023
1 parent c5ef560 commit a75ef1d
Show file tree
Hide file tree
Showing 5 changed files with 262 additions and 16 deletions.
70 changes: 64 additions & 6 deletions docs/rules/no-restricted-browser-globals-during-ssr.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,52 @@ export default class Foo extends LightningElement {
}
```

```js
import { LightningElement } from 'lwc';

export default class Foo extends LightningElement {
constructor() {
this.handleResize = this.handleResize.bind(this);
this.handleClick = this.handleClick.bind(this);
}

connectedCallback() {
globalThis.addEventListener('resize', this.handleResize);
document.addEventListener('click', this.handleClick);
}

disconnectedCallback() {
globalThis.removeEventListener('resize', this.handleResize);
document.removeEventListener('click', this.handleClick);
}

handleResize(event) {
/* ... */
}

handleClick(event) {
/* ... */
}
}
```

Examples of **correct** code for this rule:

```js
import { LightningElement } from 'lwc';

export default class Foo extends LightningElement {
connectedCallback() {
if (!import.meta.env.SSR) {
const parser = new DOMParser();
}
}
}
```
```js
import { LightningElement } from 'lwc';

export default class Foo extends LightningElement {
renderedCallback() {
const parser = new DOMParser();
Expand All @@ -35,10 +76,27 @@ export default class Foo extends LightningElement {
import { LightningElement } from 'lwc';

export default class Foo extends LightningElement {
renderedCallback() {
if (!import.meta.env.SSR) {
const parser = new DOMParser();
}
constructor() {
this.handleResize = this.handleResize.bind(this);
this.handleClick = this.handleClick.bind(this);
}

connectedCallback() {
globalThis.addEventListener?.('resize', this.handleResize);
globalThis.document?.addEventListener('click', this.handleClick);
}

disconnectedCallback() {
globalThis.removeEventListener?.('resize', this.handleResize);
globalThis.document?.removeEventListener('click', this.handleClick);
}

handleResize(event) {
/* ... */
}

handleClick(event) {
/* ... */
}
}
```
Expand All @@ -49,6 +107,6 @@ The rule takes one option, an object, which has one key `restricted-globals` whi
are strings which represent the name of the global and the values can be booleans, `true` indicating that the global
is restricted and `false` to indicate that the global is allowed (useful for overriding an already restricted global).
```js
{ "restricted-globals": { MyBrowserOnlyGlobal: true, MyAvailableGlobal: false } }
```json
{ "restricted-globals": { "MyBrowserOnlyGlobal": true, "MyAvailableGlobal": false } }
```
50 changes: 45 additions & 5 deletions lib/rule-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ function inModuleScope(node, context) {

module.exports.noReferenceDuringSSR = function noReferenceDuringSSR(
forbiddenGlobalNames,
messageId,
messageIds,
context,
) {
const {
Expand All @@ -150,15 +150,49 @@ module.exports.noReferenceDuringSSR = function noReferenceDuringSSR(
return;
}
if (
node.parent.type === 'MemberExpression' &&
node.object.type === 'Identifier' &&
forbiddenGlobalNames.has(node.object.name) &&
node.object.name === 'globalThis' &&
node.property.type === 'Identifier' &&
forbiddenGlobalNames.has(node.property.name) &&
node.parent.optional !== true &&
isGlobalIdentifier(node.object, context.getScope())
) {
// Prevents expressions like:
// globalThis.document.addEventListener('click', () => { ... });

// Allows expressions like:
// globalThis.document?.addEventListener('click', () => { ... });
context.report({
messageId: messageIds.at(1),
node,
data: {
identifier: node.property.name,
property: node.parent.property.name,
},
});
} else if (
node.parent.type !== 'MemberExpression' &&
node.object.type === 'Identifier' &&
(forbiddenGlobalNames.has(node.object.name) ||
(node.object.name === 'globalThis' && node.optional !== true)) &&
isGlobalIdentifier(node.object, context.getScope())
) {
// Prevents expressions like:
// globalThis.addEventListener('click', () => { ... });
// document.addEventListener('click', () => { ... });
// document?.addEventListener('click', () => { ... });

// Allows expressions like:
// globalThis?.addEventListener('click', () => { ... });
context.report({
messageId,
messageId: messageIds.at(0),
node,
data: {
identifier: node.object.name,
identifier:
node.object.name === 'globalThis'
? node.property.name
: node.object.name,
},
});
}
Expand All @@ -177,8 +211,14 @@ module.exports.noReferenceDuringSSR = function noReferenceDuringSSR(
forbiddenGlobalNames.has(node.name) &&
isGlobalIdentifier(node, context.getScope())
) {
// Prevents expressions like:
// doSomethingWith(window);
// doSomethingWith(document);

// Allows expressions like:
// doSomethingWith(globalThis)
context.report({
messageId,
messageId: messageIds.at(0),
node,
data: {
identifier: node.name,
Expand Down
13 changes: 10 additions & 3 deletions lib/rules/no-restricted-browser-globals-during-ssr.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const { noReferenceDuringSSR } = require('../rule-helpers');
const { docUrl } = require('../util/doc-url');

const forbiddenGlobalNames = new Set(Object.keys(browser));
forbiddenGlobalNames.delete('globalThis'); // This case we treat separately
for (const allowedGlobal of Object.keys(nodeBuiltin)) {
forbiddenGlobalNames.delete(allowedGlobal);
}
Expand Down Expand Up @@ -37,12 +38,14 @@ module.exports = {
],
messages: {
prohibitedBrowserAPIUsage:
'Invalid usage of a browser global API during SSR. Consider moving `{{identifier}}` to the `renderedCallback`.',
'Invalid usage of a browser global API during SSR. Consider guarding access to `{{identifier}}`, e.g. via the `import.meta.env.SSR` flag, or optional chaining (`globalThis?.{{identifier}}`).',
prohibitedBrowserAPIUsageGlobal:
'Invalid usage of a browser global API during SSR. Consider guarding access to `{{identifier}}.{{property}}`, e.g. via the `import.meta.env.SSR` flag, or optional chaining (`globalThis.{{identifier}}?.{{property}}`).',
},
},
create: (context) => {
const forbiddenGlobals = new Set(forbiddenGlobalNames);
const { 'restricted-globals': restrictedGlobals } = context.options[0] || {
const { 'restricted-globals': restrictedGlobals } = context.options.at(0) || {
'restricted-globals': {},
};
for (const [global, isRestricted] of Object.entries(restrictedGlobals)) {
Expand All @@ -52,6 +55,10 @@ module.exports = {
forbiddenGlobals.delete(global);
}
}
return noReferenceDuringSSR(forbiddenGlobals, 'prohibitedBrowserAPIUsage', context);
return noReferenceDuringSSR(
forbiddenGlobals,
['prohibitedBrowserAPIUsage', 'prohibitedBrowserAPIUsageGlobal'],
context,
);
},
};
2 changes: 1 addition & 1 deletion lib/util/scope.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function isGlobalIdentifier(identifier, scope) {
// If the reference is not resolved, or if it is resolved in the global scope it means the
// identifier is global. ESLint automatically add global properties in the global scope
// depending on the env config.
// eg. "browser": true will automatically inject "window" and "setTimeout" in the global scope.
// e.g. "browser": true will automatically inject "window" and "setTimeout" in the global scope.
return ref && (ref.resolved === null || ref.resolved.scope.type === 'global');
}

Expand Down
143 changes: 142 additions & 1 deletion test/lib/rules/no-restricted-browser-globals-during-ssr.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,14 +202,55 @@ tester.run('no-browser-globals-during-ssr', rule, {
}
`,
},

{
code: `btoa('lwc');`,
},
{
code: `document`,
options: [{ 'restricted-globals': { document: false } }],
},
{
code: `
import { LightningElement } from 'lwc';
export default class Foo extends LightningElement {
foo = globalThis.document?.x;
}
`,
},
{
code: `
import { LightningElement } from 'lwc';
export default class Foo extends LightningElement {
constructor() {
console.log(globalThis.document?.x);
}
}
`,
},
{
code: `
import { LightningElement } from 'lwc';
export default class Foo extends LightningElement {
connectedCallback() {
globalThis.document?.addEventListener('click', () => {});
}
}
`,
},
{
code: `
import { LightningElement } from 'lwc';
export default class Foo extends LightningElement {
connectedCallback() {
doSomethingWith(globalThis);
}
}
`,
},
],
invalid: [
{
Expand Down Expand Up @@ -498,5 +539,105 @@ tester.run('no-browser-globals-during-ssr', rule, {
},
],
},
{
code: `
import { LightningElement } from 'lwc';
export default class Foo extends LightningElement {
foo = globalThis.document.x;
}
`,
errors: [
{
messageId: 'prohibitedBrowserAPIUsageGlobal',
data: { identifier: 'document', property: 'x' },
},
],
},
{
code: `
import { LightningElement } from 'lwc';
export default class Foo extends LightningElement {
constructor() {
console.log(globalThis.document.x);
}
}
`,
errors: [
{
messageId: 'prohibitedBrowserAPIUsageGlobal',
data: { identifier: 'document', property: 'x' },
},
],
},
{
code: `
import { LightningElement } from 'lwc';
export default class Foo extends LightningElement {
connectedCallback() {
document.addEventListener('click', () => {});
}
}
`,
errors: [
{
messageId: 'prohibitedBrowserAPIUsage',
data: { identifier: 'document' },
},
],
},
{
code: `
import { LightningElement } from 'lwc';
export default class Foo extends LightningElement {
connectedCallback() {
document?.addEventListener('click', () => {});
}
}
`,
errors: [
{
messageId: 'prohibitedBrowserAPIUsage',
data: { identifier: 'document' },
},
],
},
{
code: `
import { LightningElement } from 'lwc';
export default class Foo extends LightningElement {
connectedCallback() {
globalThis.document.addEventListener('click', () => {});
}
}
`,
errors: [
{
messageId: 'prohibitedBrowserAPIUsageGlobal',
data: { identifier: 'document', property: 'addEventListener' },
},
],
},
{
code: `
import { LightningElement } from 'lwc';
export default class Foo extends LightningElement {
connectedCallback() {
globalThis.addEventListener('click', () => {});
}
}
`,
errors: [
{
messageId: 'prohibitedBrowserAPIUsage',
data: { identifier: 'addEventListener' },
},
],
},
],
});

0 comments on commit a75ef1d

Please sign in to comment.