Skip to content

Commit 496a739

Browse files
committed
More fixtures, disallow async except for event handlers.
1 parent 3b23046 commit 496a739

File tree

9 files changed

+308
-32
lines changed

9 files changed

+308
-32
lines changed

docs/reactivity.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,13 @@ createEffect(() => {
236236
runWithOwner(owner, () => console.log(signal()));
237237
});
238238

239+
const [signal] = createSignal(5);
240+
setTimeout(() => console.log(signal()), 500);
241+
setInterval(() => console.log(signal()), 600);
242+
setImmediate(() => console.log(signal()));
243+
requestAnimationFrame(() => console.log(signal()));
244+
requestIdleCallback(() => console.log(signal()));
245+
239246
```
240247
<!-- AUTO-GENERATED-CONTENT:END -->
241248

src/rules/reactivity.ts

Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
isProgramOrFunctionNode,
3333
} from "../utils";
3434

35-
const { findVariable, getFunctionHeadLocation, getFunctionNameWithKind } = ASTUtils;
35+
const { findVariable, getFunctionHeadLocation } = ASTUtils;
3636

3737
type Scope = TSESLint.Scope.Scope;
3838
type Variable = TSESLint.Scope.Variable;
@@ -58,13 +58,20 @@ interface ReactiveVariable {
5858
}
5959

6060
interface TrackedScope {
61+
/**
62+
* The root node, usually a function or JSX expression container, to allow
63+
* reactive variables under.
64+
*/
6165
node: T.Node;
62-
expect: "function" | "expression";
66+
/**
67+
* The reactive variable should be one of these types:
68+
* - "function": synchronous function or signal variable
69+
* - "event-handler": synchronous or asynchronous function like a timer or
70+
* event handler that isn't really a tracked scope but acts like one
71+
* - "expression": some value containing reactivity somewhere
72+
*/
73+
expect: "function" | "event-handler" | "expression";
6374
}
64-
const TrackedScope = (node: T.Node, expect: "function" | "expression"): TrackedScope => ({
65-
node,
66-
expect,
67-
});
6875

6976
class ScopeStackItem {
7077
/** the node for the current scope, or program if global scope */
@@ -246,10 +253,10 @@ type MessageIds =
246253
| "noWrite"
247254
| "untrackedReactive"
248255
| "badSignal"
249-
| "badProps"
250256
| "badUnnamedDerivedSignal"
251257
| "shouldDestructure"
252-
| "shouldAssign";
258+
| "shouldAssign"
259+
| "noAsyncTrackedScope";
253260

254261
const rule: TSESLint.RuleModule<MessageIds, []> = {
255262
meta: {
@@ -263,15 +270,17 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
263270
fixable: "code",
264271
schema: [],
265272
messages: {
266-
noWrite: "The reactive variable {{ name }} should not be reassigned or altered.",
267-
untrackedReactive: "The reactive variable {{ name }} should be used within a tracked scope.",
268-
badSignal: "This variable should be called as a function when used in JSX.",
269-
badProps:
270-
"This props or store should only be passed to functions or used for property access.",
271-
badUnnamedDerivedSignal: "This function should be passed to a tracked scope.",
273+
noWrite: "The reactive variable '{{name}}' should not be reassigned or altered.",
274+
untrackedReactive: "The reactive variable '{{name}}' should be used within a tracked scope.",
275+
badSignal:
276+
"The reactive variable '{{name}}' should be called as a function when used in JSX.",
277+
badUnnamedDerivedSignal:
278+
"This function should be passed to a tracked scope because it contains reactivity.",
272279
shouldDestructure:
273280
"Array destructuring should be used to capture the {{nth}}result of this function call.",
274281
shouldAssign: "A variable should be used to capture the result of this function call.",
282+
noAsyncTrackedScope:
283+
"This tracked scope should not be async. Solid's reactivity only tracks synchronously.",
275284
},
276285
},
277286
create(context) {
@@ -302,6 +311,7 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
302311
const matchTrackedScope = (trackedScope: TrackedScope, node: T.Node): boolean => {
303312
switch (trackedScope.expect) {
304313
case "function":
314+
case "event-handler":
305315
return node === trackedScope.node;
306316
case "expression":
307317
return Boolean(
@@ -443,6 +453,9 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
443453
context.report({
444454
node: identifier,
445455
messageId: "badSignal",
456+
data: {
457+
name: identifier.name,
458+
},
446459
});
447460
}
448461
// The signal is being read outside of a CallExpression. Since
@@ -666,7 +679,18 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
666679
| T.VariableDeclarator
667680
| T.AssignmentExpression
668681
) => {
669-
const { trackedScopes } = currentScope();
682+
const pushTrackedScope = (node: T.Node, expect: TrackedScope["expect"]) => {
683+
currentScope().trackedScopes.push({ node, expect });
684+
if (expect !== "event-handler" && isFunctionNode(node) && node.async) {
685+
// From the docs: "[Solid's] approach only tracks synchronously. If you
686+
// have a setTimeout or use an async function in your Effect the code
687+
// that executes async after the fact won't be tracked."
688+
context.report({
689+
node,
690+
messageId: "noAsyncTrackedScope",
691+
});
692+
}
693+
};
670694
if (node.type === "JSXExpressionContainer") {
671695
// Expect a function if the attribute is like onClick={} or on:click={}.
672696
// From the docs: Events are never rebound and the bindings are not
@@ -683,7 +707,7 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
683707
sourceCode.getText(node.parent.name).match(/^on[:A-Z]/)
684708
? "function"
685709
: "expression";
686-
trackedScopes.push(TrackedScope(node.expression, expect));
710+
pushTrackedScope(node.expression, expect);
687711
} else if (node.type === "CallExpression" && node.callee.type === "Identifier") {
688712
const callee = node.callee;
689713
const arg0 = node.arguments[0];
@@ -694,8 +718,6 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
694718
"createEffect",
695719
"onMount",
696720
"createRenderEffect",
697-
"untrack",
698-
"batch",
699721
"createDeferred",
700722
"createComputed",
701723
"createSelector",
@@ -704,23 +726,36 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
704726
) {
705727
// createEffect, createMemo, etc. fn arg, and createResource optional
706728
// `source` first argument may be a signal
707-
trackedScopes.push(TrackedScope(arg0, "function"));
729+
pushTrackedScope(arg0, "function");
730+
} else if (
731+
[
732+
"setInterval",
733+
"setTimeout",
734+
"setImmediate",
735+
"requestAnimationFrame",
736+
"requestIdleCallback",
737+
].includes(callee.name)
738+
) {
739+
// Timers are NOT tracked scopes. However, they don't need to react
740+
// to updates to reactive variables; it's okay to poll the current
741+
// value of a signal. Consider them tracked scopes for our purposes.
742+
pushTrackedScope(arg0, "event-handler");
708743
} else if (callee.name === "createMutable" && arg0) {
709-
trackedScopes.push(TrackedScope(arg0, "expression"));
744+
pushTrackedScope(arg0, "expression");
710745
} else if (callee.name === "on") {
711746
// on accepts a signal or an array of signals as its first argument,
712747
// and a tracking function as its second
713748
if (arg0) {
714749
if (arg0.type === "ArrayExpression") {
715750
arg0.elements.forEach((element) => {
716-
trackedScopes.push(TrackedScope(element, "function"));
751+
pushTrackedScope(element, "function");
717752
});
718753
} else {
719-
trackedScopes.push(TrackedScope(arg0, "function"));
754+
pushTrackedScope(arg0, "function");
720755
}
721756
}
722757
if (node.arguments[1]) {
723-
trackedScopes.push(TrackedScope(node.arguments[1], "function"));
758+
pushTrackedScope(node.arguments[1], "function");
724759
}
725760
} else if (callee.name === "runWithOwner") {
726761
// runWithOwner(owner, fn) only creates a tracked scope if `owner =
@@ -758,7 +793,7 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
758793
}
759794
}
760795
if (isTrackedScope) {
761-
trackedScopes.push(TrackedScope(node.arguments[1], "function"));
796+
pushTrackedScope(node.arguments[1], "function");
762797
}
763798
}
764799
}
@@ -778,7 +813,7 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
778813
reference.identifier.parent.callee === reference.identifier
779814
) {
780815
const arg0 = reference.identifier.parent.arguments[0];
781-
arg0 && trackedScopes.push(TrackedScope(arg0, "function"));
816+
arg0 && pushTrackedScope(arg0, "function");
782817
}
783818
}
784819
}
@@ -799,7 +834,7 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
799834
// where event handlers are manually attached to refs, detect these
800835
// scenarios and mark the right hand sides as tracked scopes expecting
801836
// functions.
802-
trackedScopes.push(TrackedScope(node.right, "function"));
837+
pushTrackedScope(node.right, "event-handler");
803838
}
804839
}
805840
};

test/fixture/valid/async-resource.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// @ts-nocheck
2+
import { createSignal, createResource } from "solid-js";
3+
import { render } from "solid-js/web";
4+
5+
const fetchUser = async (id) => (await fetch(`https://swapi.dev/api/people/${id}/`)).json();
6+
7+
const App = () => {
8+
const [userId, setUserId] = createSignal();
9+
const [user] = createResource(userId, fetchUser);
10+
11+
return (
12+
<>
13+
<input
14+
type="number"
15+
min="1"
16+
placeholder="Enter Numeric Id"
17+
onInput={(e) => setUserId(e.currentTarget.value)}
18+
/>
19+
<span>{user.loading && "Loading..."}</span>
20+
<div>
21+
<pre>{JSON.stringify(user(), null, 2)}</pre>
22+
</div>
23+
</>
24+
);
25+
};
26+
27+
render(App, document.getElementById("app"));
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// @ts-nocheck
2+
import { render } from "solid-js/web";
3+
import { onCleanup, createSignal } from "solid-js";
4+
5+
const CountingComponent = () => {
6+
const [count, setCount] = createSignal(0);
7+
const interval = setInterval(() => setCount((count) => count + 1), 1000);
8+
onCleanup(() => clearInterval(interval));
9+
return <div>Count value is {count()}</div>;
10+
};
11+
12+
render(() => <CountingComponent />, document.getElementById("app"));
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// @ts-nocheck
2+
import { render } from "solid-js/web";
3+
import { createSignal } from "solid-js";
4+
5+
function Counter() {
6+
const [count, setCount] = createSignal(0);
7+
8+
setTimeout(() => console.log(count()), 4500);
9+
10+
return <div>Count: {count()}</div>;
11+
}
12+
13+
render(() => <Counter />, document.getElementById("app"));

test/fixture/valid/scoreboard.tsx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// @ts-nocheck
2+
import { createMemo, createSignal, createComputed, onCleanup, For } from "solid-js";
3+
import { createStore } from "solid-js/store";
4+
import { render } from "solid-js/web";
5+
6+
const App = () => {
7+
let newName, newScore;
8+
const [state, setState] = createStore({
9+
players: [
10+
{ name: "Mark", score: 3 },
11+
{ name: "Troy", score: 2 },
12+
{ name: "Jenny", score: 1 },
13+
{ name: "David", score: 8 },
14+
],
15+
}),
16+
lastPos = new WeakMap(),
17+
curPos = new WeakMap(),
18+
getSorted = createMemo((list = []) => {
19+
list.forEach((p, i) => lastPos.set(p, i));
20+
const newList = state.players.slice().sort((a, b) => {
21+
if (b.score === a.score) return a.name.localeCompare(b.name); // stabalize the sort
22+
return b.score - a.score;
23+
});
24+
let updated = newList.length !== list.length;
25+
newList.forEach((p, i) => lastPos.get(p) !== i && (updated = true) && curPos.set(p, i));
26+
return updated ? newList : list;
27+
}),
28+
handleAddClick = () => {
29+
const name = newName.value,
30+
score = +newScore.value;
31+
if (!name.length || isNaN(score)) return;
32+
setState("players", (p) => [...p, { name: name, score: score }]);
33+
newName.value = newScore.value = "";
34+
},
35+
handleDeleteClick = (player) => {
36+
const idx = state.players.indexOf(player);
37+
setState("players", (p) => [...p.slice(0, idx), ...p.slice(idx + 1)]);
38+
},
39+
handleScoreChange = (player, { target }) => {
40+
const score = +target.value;
41+
const idx = state.players.indexOf(player);
42+
if (isNaN(+score) || idx < 0) return;
43+
setState("players", idx, "score", score);
44+
},
45+
createStyles = (player) => {
46+
const [style, setStyle] = createSignal();
47+
createComputed(() => {
48+
getSorted();
49+
const offset = lastPos.get(player) * 18 - curPos.get(player) * 18,
50+
t = setTimeout(() => setStyle({ transition: "250ms", transform: null }));
51+
setStyle({
52+
transform: `translateY(${offset}px)`,
53+
transition: null,
54+
});
55+
onCleanup(() => clearTimeout(t));
56+
});
57+
return style;
58+
};
59+
60+
return (
61+
<div id="scoreboard">
62+
<div class="board">
63+
<For each={getSorted()}>
64+
{(player) => {
65+
const getStyles = createStyles(player),
66+
{ name } = player;
67+
return (
68+
<div class="player" style={getStyles()}>
69+
<div class="name">{name}</div>
70+
<div class="score">{player.score}</div>
71+
</div>
72+
);
73+
}}
74+
</For>
75+
</div>
76+
<form class="admin">
77+
<For each={state.players}>
78+
{(player) => {
79+
const { name, score } = player;
80+
return (
81+
<div class="player">
82+
{name}
83+
<input
84+
class="score"
85+
type="number"
86+
value={score}
87+
onInput={[handleScoreChange, player]}
88+
/>
89+
<button type="button" onClick={[handleDeleteClick, player]}>
90+
x
91+
</button>
92+
</div>
93+
);
94+
}}
95+
</For>
96+
<div class="player">
97+
<input type="text" name="name" placeholder="New player..." ref={newName} />
98+
<input class="score" type="number" name="score" ref={newScore} />
99+
<button type="button" onClick={handleAddClick}>
100+
Add
101+
</button>
102+
</div>
103+
</form>
104+
</div>
105+
);
106+
};
107+
108+
render(App, document.getElementById("app"));

0 commit comments

Comments
 (0)