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: add eslint rules for SSR #107

Merged
merged 31 commits into from
Feb 20, 2023
Merged

feat: add eslint rules for SSR #107

merged 31 commits into from
Feb 20, 2023

Conversation

abdulsattar
Copy link
Contributor

@abdulsattar abdulsattar commented Dec 19, 2022

@divmain is the main author of this commit. I'm moving them from his repo this.

This PR introduces five new rules that check that browser APIs aren't used anywhere where SSR is done.

  • It will fail only if SSR-unfriendly code is found in specific methods. For example, you can use this.template.querySelector in renderedCallback but not in connectedCallback.
  • This is also true if your lifecycle method invokes another method on the LWC. For example, if connectedCallback calls this.foo() and foo tries to access document, the linter will complain.
  • This is also true for functions defined in module scope. For example, connectedCallback might call doSomething(), which is defined in the same module like so function doSomething() { document.write("<div/>") }. In this case, an error will be surfaced.
  • The linter cannot follow invocation patterns across module boundaries. For example, if doSomething is defined in a different module than your LWC, you're on your own and the linter can't help you.
  • Most browser APIs are locked down. You can't use this.template in SSR. You can't touch most browser global variables that aren't also available in Node.
  • Any if statements that have if(document !== undefined) are ignored. We don't check the else blocks as well.

@abdulsattar abdulsattar marked this pull request as ready for review December 20, 2022 09:42
@abdulsattar abdulsattar marked this pull request as draft December 20, 2022 09:46
@abdulsattar abdulsattar marked this pull request as ready for review January 20, 2023 10:46
.eslintrc Outdated Show resolved Hide resolved
docs/rules/no-document-during-ssr.md Outdated Show resolved Hide resolved
docs/rules/no-document-during-ssr.md Outdated Show resolved Hide resolved
docs/rules/no-document-during-ssr.md Outdated Show resolved Hide resolved
docs/rules/no-querying-slots-during-ssr.md Outdated Show resolved Hide resolved
docs/rules/no-restricted-browser-globals-during-ssr.md Outdated Show resolved Hide resolved
docs/rules/no-this-template-during-ssr.md Outdated Show resolved Hide resolved
docs/rules/no-this-template-during-ssr.md Outdated Show resolved Hide resolved
docs/rules/no-window-during-ssr.md Outdated Show resolved Hide resolved
Copy link
Contributor

@divmain divmain left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great to me!

lib/analyze-component.js Outdated Show resolved Hide resolved
lib/analyze-component.js Show resolved Hide resolved
'captureEvents',
'chrome',
'clientInformation',
// 'close',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We chatted about this on Slack, but also leaving this here for other reviewers & for posterity:

In the future, we can use ESlint's scope API to determine whether a variable is locally scoped or a global. When that is in place, we can uncomment these global variables with common names.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future, we can use ESlint's scope API to determine whether a variable is locally scoped or a global.

IMO, it's something we should have from day 1. If an ESLint rule reports noise, developers will certainly disable this rule globally and never enable it again.

I am ok with merging this PR to get the ball rolling, but I don't think those rules should be used until we resolve this scoping issue.

schema: [],
messages: {
prohibitedBrowserAPIUsage:
'`{{identifier}}`, like most browser APIs, is not accessible during SSR.',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'`{{identifier}}`, like most browser APIs, is not accessible during SSR.',
'Invalid usage of a browser global API during SSR. Consider moving `{{identifier}}` to the `renderedCallback` ',

const { noReferenceDuringSSR } = require('../rule-helpers');
const { docUrl } = require('../util/doc-url');

const prohibitedGlobalVariables = new Set([
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The list of known browser APIs is consistently evolving. We should avoid having to maintain this list.
ESLint is using internal the globals npm package to determine which global is available per environment. I think we should do the same.

'captureEvents',
'chrome',
'clientInformation',
// 'close',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the future, we can use ESlint's scope API to determine whether a variable is locally scoped or a global.

IMO, it's something we should have from day 1. If an ESLint rule reports noise, developers will certainly disable this rule globally and never enable it again.

I am ok with merging this PR to get the ball rolling, but I don't think those rules should be used until we resolve this scoping issue.

lib/walk.js Show resolved Hide resolved

const tester = new RuleTester(ESLINT_TEST_CONFIG);

const disallowedProperties = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto. Let's avoid having to maintain multiple disallow lists.

errors: [
{
message:
'You should not use `this.template.querySelector` in methods that will execute during SSR. Use `lwc:ref` instead.',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error message is misleading. Even with refs, you can't access the rendered element in the connectedCallback (on the client nor the server).

errors: [
{
message:
'You should not access any DOM properties on `this` in methods that will execute during SSR.',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should not access any DOM properties on this in methods that will execute during SSR.

Is this statement really true? As far as I know, we do implement at least this.classList, this.getAttribute, and this.setAttribute. Does it mean that we should retire those APIs?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading is fine. Writing is not fine and causes hydration errors. An improvement would be to restrict the developer only from using APIs that change the DOM. Would you be okay with this going into a follow-up PR, @pmdartus?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure. I don't see any issue with doing this work in a follow-up PR as long as we do it prior to socializing those new rules.

const { noPropertyAccessDuringSSR } = require('../rule-helpers');
const { docUrl } = require('../util/doc-url');

const disallowedProperties = [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only a subset of those APIs is implemented by LightningElement today. Could we be more selective?

@@ -0,0 +1,48 @@
# Disallow access of properties on this during SSR (`lwc/no-this-property-during-ssr`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to better understand the motivation behind this rule. It appears to me that we are bundling multiple potential SSR errors in a single rule:

  • Access to certain DOM APIs that aren't implemented by the LWC engine server: eg. dispatchEvent
  • Access to rendered content prior to the component being rendered this[.template].querySelector
  • Access to the parent node via the template this.template.host
  • Access to DOM APIs poly filled on the LWC engine server: eg. this.getAttribute, this.setAttribute, this.classList

Could we tease those use cases apart and evaluate the usefulness of each of them individually?

lib/rules/no-restricted-browser-globals-during-ssr.js Outdated Show resolved Hide resolved
lib/rules/no-restricted-browser-globals-during-ssr.js Outdated Show resolved Hide resolved
node.test.left.type === 'UnaryExpression' &&
node.test.left.operator === 'typeof' &&
node.test.left.argument.type === 'Identifier' &&
(node.test.left.argument.name === 'document' || node.test.left.argument.name === 'window')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is also something we should parameterize. I would expect LWR to come with a more elegant option than type 'window' checks.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we land imports.meta.env, we can change this to check for that.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sound good to me. Could you add a comment here, to make sure we track this in the future?

README.md Outdated Show resolved Hide resolved
@abdulsattar abdulsattar requested a review from pmdartus February 20, 2023 07:28
Copy link
Member

@pmdartus pmdartus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is one bug I would recommend addressing, and a couple of comments. Otherwise looks good.

const { 'restricted-globals': restrictedGlobals } = context.options[0] || {
'restricted-globals': {},
};
for (const [global, isRestricted] of Object.entries(restrictedGlobals)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since rules can be applied on a per-file basis, we shouldn't mutate the forbiddenGlobalNames singleton object. The forbiddenGlobalNames should be copied first before applying the configuration.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are not modifying the singleton. In line 44, we're cloning it and modifying the clone.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right. I haven't read the code carefully enough.

node.test.left.type === 'UnaryExpression' &&
node.test.left.operator === 'typeof' &&
node.test.left.argument.type === 'Identifier' &&
(node.test.left.argument.name === 'document' || node.test.left.argument.name === 'window')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sound good to me. Could you add a comment here, to make sure we track this in the future?

@abdulsattar abdulsattar merged commit 286da2d into master Feb 20, 2023
@abdulsattar abdulsattar deleted the abdulsattar/ssr branch February 20, 2023 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants