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

feat: update rule no-restricted-browser-globals-during-ssr to reflect best practices @W-14336890 #132

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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' },
},
],
},
],
});