Skip to content

Commit d8784e3

Browse files
authored
Merge pull request #67 from solidjs-community/fix/as-expression
Treat type casts and assertions as transparent in reactivity analysis.
2 parents d350c8e + c7dab0e commit d8784e3

File tree

5 files changed

+113
-10
lines changed

5 files changed

+113
-10
lines changed

docs/reactivity.md

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -557,10 +557,16 @@ function createCustomStore() {
557557
);
558558
}
559559

560+
const m = createMemo(() => 5) as Accessor<number>;
561+
562+
const m = createMemo(() => 5)!;
563+
564+
const m = createMemo(() => 5)! as Accessor<number>;
565+
560566
```
561567
<!-- AUTO-GENERATED-CONTENT:END -->
562568

563-
### Implementation
569+
## Implementation
564570

565571
We analyze in a single pass but take advantage of ESLint's ":exit" selector
566572
to take action both on the way down the tree and on the way back up. At any
@@ -586,3 +592,76 @@ Notes:
586592
match a tracked scope that expects a function.
587593
- This rule ignores classes. Solid is based on functions/closures only, and
588594
it's uncommon to see classes with reactivity in Solid code.
595+
596+
## Implementation v2 (in progress)
597+
598+
`solid/reactivity` has been public for exactly one year (!) at the time of writing, and after lots
599+
of feedback, testing, and changes, I've noticed a few problems with its first implementation:
600+
601+
- **Hard to change.** All of the data structure, analysis, and reporting code is colocated in a
602+
single file with all the cases for detecting signals, props, and tracked scopes based on Solid
603+
APIs. There's a few edge cases where detection code is mixed with analysis code. This makes it
604+
hard for contributors to make PRs and hard for others to be able to maintain it.
605+
- **Limited to variables.** The analysis code relies heavily on ESLint's `ScopeManager` and scope
606+
utilities, and therefore it can only deal with variable references, not implicitly reactive
607+
expressions.
608+
- **Non-extensible.** Since detection code is hardcoded in the `reactivity.ts` file, it is plainly
609+
not possible for users to mark certain APIs as reactive in some way.
610+
- **Susceptible to timing issues.** I'm very proud of the rule's stack-based algorithm for running
611+
signal/props/tracked scope detection and reactivity analysis in a single pass. Its performance is
612+
going to be extremely difficult to beat. But the single-pass approach puts complex requirements on
613+
the detection phase—signals and props have to be marked before nested `function:exit`s, relying on
614+
initialization before usage in source order. That's not a requirement I feel comfortable putting on
615+
plugin authors, or really even myself in a few months.
616+
617+
So, I've decided to partially rewrite the rule with a plugin architecture to alleviate these issues.
618+
Both the core detection code and any plugins to alter detection will use the same API.
619+
620+
### Ease of change and extensibility: Plugins (Customizations)
621+
622+
`solid/reactivity`, itself part of an ESLint plugin, will support plugins of its own.
623+
624+
`eslint-plugin-solid` will expose a CLI command `eslint-plugin-solid` that searches
625+
`package.json` files in the current working directory and its `node_modules` for a
626+
`"solid/reactivity"` key at the top level (raw string search first for perf). This key will be expected to
627+
contain a relative path to a CommonJS or native ESM file, accessible from requiring a subpath of the
628+
module. For example:
629+
630+
```ts
631+
const packageJson = { "solid/reactivity": "./reactivity-plugin.js" };
632+
require.resolve(`${packageJson.name}/${packageJson["solid/reactivity"]}`);
633+
// path to reactivity plugin
634+
```
635+
636+
The command will not run any code in `node_modules`; it will just print out an example ESLint config for
637+
the `solid/reactivity` rule, configured to load all plugins found. For example:
638+
639+
```json
640+
"solid/reactivity": [1, {
641+
"plugins": ["./node_modules/some-module-with-plugin/solid-reactivity-plugin.cjs"]
642+
}]
643+
```
644+
645+
This code can be inspected to ensure it matches expections, or edited to add additional paths to
646+
more plugins. You can manually configure a particular path as a plugin without running the CLI at
647+
all. At runtime, any plugins configured will be loaded and run alongside the base rules. Custom
648+
hooks (`use*`/`create*`) from imported from these packages will not be treated permissively, others
649+
will.
650+
651+
> `eslint-plugin-solid` will **not** automatically load plugins. They must be preconfigured in an
652+
> ESLint config file.
653+
654+
### Expression Support
655+
656+
Having detection code being moved to plugins and an API boundary lets us put reference tracking into
657+
the analysis code, so analyzing arbitrary expressions for reactivity becomes easier. Instead of
658+
using `TSESLint.Scope.Reference`s only, a custom data structure can be built to handle any `Node` from
659+
the plugin API.
660+
661+
### Timing
662+
663+
This is a good opportunity to transition from a stack-based algorithm, where information is lost
664+
after the exit pass, to a tree-based algorithm that can capture all reactivity information in a data
665+
structure. By using the built tree to walk through the final analysis, and colocating references
666+
with their associated scopes, performance should stay good. The reactivity rule could then power
667+
an editor plugin to show signals, tracked scopes, etc.

scripts/docs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ const buildOptions = (filename: string): string => {
104104
].join("\n");
105105
};
106106

107-
const pretty = (code: string) => prettier.format(code, { parser: "babel" }).trim();
107+
const pretty = (code: string) => prettier.format(code, { parser: "typescript" }).trim();
108108
const options = (options: Array<any>) =>
109109
options
110110
.map((o) =>

src/rules/reactivity.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
isProgramOrFunctionNode,
1515
trackImports,
1616
isDOMElementName,
17+
ignoreTransparentWrappers,
1718
} from "../utils";
1819

1920
const { findVariable, getFunctionHeadLocation } = ASTUtils;
@@ -159,6 +160,7 @@ class ScopeStack extends Array<ScopeStackItem> {
159160
reference,
160161
declarationScope: variable.declarationScope,
161162
}));
163+
// I don't think this is needed! Just a perf optimization
162164
variable.references = notInScope;
163165
}
164166
}
@@ -667,8 +669,10 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
667669
/** Checks VariableDeclarators, AssignmentExpressions, and CallExpressions for reactivity. */
668670
const checkForReactiveAssignment = (
669671
id: T.BindingName | T.AssignmentExpression["left"] | null,
670-
init: T.Expression
672+
init: T.Node
671673
) => {
674+
init = ignoreTransparentWrappers(init);
675+
672676
// Mark return values of certain functions as reactive
673677
if (init.type === "CallExpression" && init.callee.type === "Identifier") {
674678
const { callee } = init;
@@ -871,8 +875,6 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
871875
// to updates to reactive variables; it's okay to poll the current
872876
// value. Consider them called-function tracked scopes for our purposes.
873877
pushTrackedScope(arg0, "called-function");
874-
} else if (matchImport("createMutable", callee.name) && arg0) {
875-
pushTrackedScope(arg0, "expression");
876878
} else if (matchImport("on", callee.name)) {
877879
// on accepts a signal or an array of signals as its first argument,
878880
// and a tracking function as its second
@@ -1051,10 +1053,8 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
10511053
checkForSyncCallbacks(node);
10521054

10531055
// ensure calls to reactive primitives use the results.
1054-
if (
1055-
node.parent?.type !== "AssignmentExpression" &&
1056-
node.parent?.type !== "VariableDeclarator"
1057-
) {
1056+
const parent = node.parent && ignoreTransparentWrappers(node.parent, true);
1057+
if (parent?.type !== "AssignmentExpression" && parent?.type !== "VariableDeclarator") {
10581058
checkForReactiveAssignment(null, node);
10591059
}
10601060
},

src/utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ export function trace(node: T.Node, initialScope: TSESLint.Scope.Scope): T.Node
6969
return node;
7070
}
7171

72+
/** Get the relevant node when wrapped by a node that doesn't change the behavior */
73+
export function ignoreTransparentWrappers(node: T.Node, up = false): T.Node {
74+
if (node.type === "TSAsExpression" || node.type === "TSNonNullExpression") {
75+
const next = up ? node.parent : node.expression;
76+
if (next) {
77+
return ignoreTransparentWrappers(next, up);
78+
}
79+
}
80+
return node;
81+
}
82+
7283
export type FunctionNode = T.FunctionExpression | T.ArrowFunctionExpression | T.FunctionDeclaration;
7384
const FUNCTION_TYPES = ["FunctionExpression", "ArrowFunctionExpression", "FunctionDeclaration"];
7485
export const isFunctionNode = (node: T.Node | null | undefined): node is FunctionNode =>

test/rules/reactivity.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { AST_NODE_TYPES as T } from "@typescript-eslint/utils";
2-
import { run } from "../ruleTester";
2+
import { run, tsOnlyTest } from "../ruleTester";
33
import rule from "../../src/rules/reactivity";
44

55
export const cases = run("reactivity", rule, {
@@ -251,6 +251,19 @@ export const cases = run("reactivity", rule, {
251251
(item) => ({})
252252
);
253253
}`,
254+
// type casting
255+
{
256+
code: `const m = createMemo(() => 5) as Accessor<number>;`,
257+
...tsOnlyTest,
258+
},
259+
{
260+
code: `const m = createMemo(() => 5)!;`,
261+
...tsOnlyTest,
262+
},
263+
{
264+
code: `const m = createMemo(() => 5)! as Accessor<number>;`,
265+
...tsOnlyTest,
266+
},
254267
],
255268
invalid: [
256269
// Untracked signals

0 commit comments

Comments
 (0)