Skip to content

Commit

Permalink
[skip ci] WIP building out getReferences for approximate feature parity
Browse files Browse the repository at this point in the history
  • Loading branch information
joshwilsonvu committed Mar 18, 2023
1 parent 3492bb4 commit c1e4b55
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 71 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"jsx-ast-utils": "^3.3.2",
"kebab-case": "^1.0.1",
"known-css-properties": "^0.24.0",
"style-to-object": "^0.3.0"
"style-to-object": "^0.3.0",
"tiny-invariant": "^1.3.1"
},
"devDependencies": {
"@babel/core": "^7.18.9",
Expand Down
8 changes: 7 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/rules/reactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,7 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
};

/*
* Sync array functions (forEach, map, reduce, reduceRight, flatMap),
* Sync array functions (forEach, map, reduce, reduceRight, flatMap), IIFEs,
* store update fn params (ex. setState("todos", (t) => [...t.slice(0, i()),
* ...t.slice(i() + 1)])), batch, onCleanup, and onError fn params, and
* maybe a few others don't actually create a new scope. That is, any
Expand Down
24 changes: 22 additions & 2 deletions src/rules/reactivity/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,28 @@ interface TrackedScope {
}

export interface VirtualReference {
reference: TSESLint.Scope.Reference | T.Node; // store references directly instead of pushing variables?
declarationScope: ReactivityScope;
/**
* The node, commonly an identifier, where reactivity is referenced.
*/
node: T.Node;
/**
* The scope where the variable `node` is referencing is declared, or `null` if no variable.
* Since reactivity is, by definition, set up once and accessed multiple times, the final
* call or property access in a reactive expression's `path` is the access that's reactive.
* Previous `path` segments just describe the API. It's safe to assume that the second-to-last
* `path` segment defines the declaration scope unless the reactive expression is a derived signal.
*
* @example
* function Component() {
* const [signal1] = createSignal(42);
* const signal2 = createSignal(42)[0];
* const signal3 = createSignal(42)[0];
* const d = () => {
* console.log(signal()); // declarationScope === Component
* }
* console.log(createSignal(42)[0]()); // declarationScope ===
*/
declarationScope: ReactivityScope | null;
}

function isRangeWithin(inner: T.Range, outer: T.Range): boolean {
Expand Down
75 changes: 33 additions & 42 deletions src/rules/reactivity/pluginApi.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { TSESTree as T, TSESLint, TSESTree } from "@typescript-eslint/utils";
import { FunctionNode, ProgramOrFunctionNode } from "../../utils";

type PathSegment = `[${number}]`;
export type ExprPath = PathSegment; // PathSegment | `${PathSegment}${Path}`;


export interface ReactivityPluginApi {
/**
Expand All @@ -26,25 +25,50 @@ export interface ReactivityPluginApi {
provideErrorContext(node: T.Node, errorMessage: string): void;

/**
* Mark that a node evaluates to a signal (or memo, etc.).
* Mark that a node evaluates to a signal (or memo, etc.). Short for `.reactive(node, (path ?? '') + '()')`
*/
signal(node: T.Node, path?: ExprPath): void;
signal(node: T.Node, path?: string): void;

/**
* Mark that a node evaluates to a store or props.
* Mark that a node evaluates to a store or props. Short for `.reactive(node, (path ?? '') + '.**')`
*/
store(node: T.Node, path?: ExprPath, options?: { mutable?: boolean }): void;
store(node: T.Node, path?: string, options?: { mutable?: boolean }): void;

/**
* Mark that a node is reactive in some way--either a signal-like or a store-like.
* Mark that a node is reactive in some way--either a signal-like or a store-like, or something
* more specialized.
* @param path a string with a special pattern, akin to a "type" for reactivity. Composed of the
* following segments:
* - `()`: when called
* - `[0]`: when the first element is accessed
* - `[]`: when any element is accessed
* - `.foo`: when the 'foo' property is accessed
* - `.*`: when any direct property is accessed
* - `.**`: when any direct or nested property is accessed
* @example
* if (isCall(node, "createSignal")) {
* reactive(node, '[0]()');
* } else if (isCall(node, "createMemo")) {
* reactive(node, '()');
* } else if (isCall(node, "splitProps")) {
* reactive(node, '[].**');
* } else if (isCall(node, "createResource")) {
* reactive(node, '()');
* reactive(node, '.loading');
* reactive(node, '.error');
* }
*/
reactive(node: T.Node, path?: ExprPath): void;
reactive(node: T.Node, path: string): void;

/**
* Convenience method for checking if a node is a call expression. If `primitive` is provided,
* checks that the callee is a Solid import with that name (or one of that name), handling aliases.
*/
isCall(node: T.Node, primitive?: string | Array<string>): node is T.CallExpression;
isCall(
node: T.Node,
primitive?: string | Array<string> | RegExp,
module?: string | RegExp
): node is T.CallExpression;
}

export interface ReactivityPlugin {
Expand All @@ -58,36 +82,3 @@ export function plugin(p: ReactivityPlugin): ReactivityPlugin {
}

// ===========================

class Reactivity implements ReactivityPluginApi {
trackedScope(node: T.Node): void {
throw new Error("Method not implemented.");
}
calledFunction(node: T.Node): void {
throw new Error("Method not implemented.");
}
trackedFunction(node: T.Node): void {
throw new Error("Method not implemented.");
}
trackedExpression(node: T.Node): void {
throw new Error("Method not implemented.");
}
syncCallback(node: T.Node): void {
throw new Error("Method not implemented.");
}
provideErrorContext(node: T.Node, errorMessage: string): void {
throw new Error("Method not implemented.");
}
signal(node: T.Node, path?: `[${number}]` | undefined): void {
throw new Error("Method not implemented.");
}
store(node: T.Node, path?: `[${number}]` | undefined): void {
throw new Error("Method not implemented.");
}
reactive(node: T.Node, path?: `[${number}]` | undefined): void {
throw new Error("Method not implemented.");
}
isCall(node: T.Node, primitive?: string | string[] | undefined): node is T.CallExpression {
throw new Error("Method not implemented.");
}
}
109 changes: 88 additions & 21 deletions src/rules/reactivity/rule.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
import { TSESLint, TSESTree as T } from "@typescript-eslint/utils";
import { ProgramOrFunctionNode, FunctionNode, trackImports, isFunctionNode } from "../../utils";
import { TSESLint, TSESTree as T, ASTUtils } from "@typescript-eslint/utils";
import invariant from 'tiny-invariant'
import {
ProgramOrFunctionNode,
FunctionNode,
trackImports,
isFunctionNode,
ignoreTransparentWrappers,
} from "../../utils";
import { ReactivityScope, VirtualReference } from "./analyze";
import type { ExprPath, ReactivityPlugin, ReactivityPluginApi } from "./pluginApi";
import type { ReactivityPlugin, ReactivityPluginApi } from "./pluginApi";

const { findVariable } = ASTUtils;

function parsePath(path: string): Array<string> | null {
if (path) {
const regex = /\(\)|\[\d*\]|\.(?:\w+|**?)/g;
const matches = path.match(regex);
// ensure the whole string is matched
if (matches && matches.reduce((acc, match) => acc + match.length, 0) === path.length) {
return matches;
}
}
return null;
}

type MessageIds =
| "noWrite"
Expand All @@ -11,7 +32,8 @@ type MessageIds =
| "badUnnamedDerivedSignal"
| "shouldDestructure"
| "shouldAssign"
| "noAsyncTrackedScope";
| "noAsyncTrackedScope"
| "jsxReactiveVariable";

const rule: TSESLint.RuleModule<MessageIds, []> = {
meta: {
Expand Down Expand Up @@ -39,12 +61,13 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
"For proper analysis, a variable should be used to capture the result of this function call.",
noAsyncTrackedScope:
"This tracked scope should not be async. Solid's reactivity only tracks synchronously.",
jsxReactiveVariable: "This variable should not be used as a JSX element.",
},
},
create(context) {
const sourceCode = context.getSourceCode();

const { handleImportDeclaration, matchImport, matchLocalToModule } = trackImports(/^/);
const { handleImportDeclaration, matchImport, matchLocalToModule } = trackImports();

const root = new ReactivityScope(sourceCode.ast, null);
const syncCallbacks = new Set<FunctionNode>();
Expand Down Expand Up @@ -92,22 +115,64 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
}
}

function getReferences(node: T.Expression, path: ExprPath) {
if (node.parent?.type === "VariableDeclarator" && node.parent.init === node) {

function VirtualReference(node: T.Node): VirtualReference {
return { node, declarationScope: }
}

/**
* Given what's usually a CallExpression and a description of how the expression must be used
* in order to be accessed reactively, return a list of virtual references for each place where
* a reactive expression is accessed.
* `path` is a string formatted according to `pluginApi`.
*/
function* getReferences(node: T.Expression, path: string, allowMutable = false): Generator<VirtualReference> {
node = ignoreTransparentWrappers(node, "up");
if (!path) {
yield VirtualReference(node);
} else if (node.parent?.type === "VariableDeclarator" && node.parent.init === node) {
const { id } = node.parent;
if (id.type === "Identifier") {
const variable = findVariable(context.getScope(), id);
if (variable) {
for (const reference of variable.references) {
if (reference.init) {
// ignore
} else if (reference.identifier.type === "JSXIdentifier") {
context.report({ node: reference.identifier, messageId: "jsxReactiveVariable" });
} else if (reference.isWrite()) {
if (!allowMutable) {
context.report({ node: reference.identifier, messageId: "noWrite" });
}
} else {
yield* getReferences(reference.identifier, path);
}
}
}
} else if (id.type === "ArrayPattern") {
const parsedPath = parsePath(path)
if (parsedPath) {
const newPath = path.substring(match[0].length);
const index = match[1]
if (index === '*') {

} else {

}
}

}
}
}

function distributeReferences(root: ReactivityScope, references: Array<VirtualReference>) {
references.forEach((ref) => {
const range =
"range" in ref.reference ? ref.reference.range : ref.reference.identifier.range;
const scope = root.deepestScopeContaining(range)!;
const range = ref.node.range;
const scope = root.deepestScopeContaining(range);
invariant(scope != null)
scope.references.push(ref);
});
}


const pluginApi: ReactivityPluginApi = {
calledFunction(node) {
currentScope.trackedScopes.push({ node, expect: "called-function" });
Expand All @@ -121,19 +186,21 @@ const rule: TSESLint.RuleModule<MessageIds, []> = {
provideErrorContext(node) {
currentScope.errorContexts.push(node);
},
signal(node, path) {

const references = []; // TODO generate virtual signal references
undistributedReferences.push(...references);
},
store(node, path, options) {
const references = []; // TODO generate virtual store references
undistributedReferences.push(...references);
},
// signal(node, path) {
// const references = []; // TODO generate virtual signal references
// undistributedReferences.push(...references);
// },
// store(node, path, options) {
// const references = []; // TODO generate virtual store references
// undistributedReferences.push(...references);
// },
reactive(node, path) {
const references = []; // TODO generate virtual reactive references
undistributedReferences.push(...references);
},
getReferences(node, path) {
return Array.from(getReferences(node, path));
},
isCall(node, primitive): node is T.CallExpression {
return (
node.type === "CallExpression" &&
Expand Down
6 changes: 3 additions & 3 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,11 @@ export function trace(node: T.Node, initialScope: TSESLint.Scope.Scope): T.Node
}

/** Get the relevant node when wrapped by a node that doesn't change the behavior */
export function ignoreTransparentWrappers(node: T.Node, up = false): T.Node {
export function ignoreTransparentWrappers(node: T.Expression, dir = 'down'): T.Expression {
if (node.type === "TSAsExpression" || node.type === "TSNonNullExpression") {
const next = up ? node.parent : node.expression;
const next = dir === 'up' ? node.parent as T.Expression : node.expression;
if (next) {
return ignoreTransparentWrappers(next, up);
return ignoreTransparentWrappers(next, dir);
}
}
return node;
Expand Down
1 change: 1 addition & 0 deletions test/fixture/valid/async/suspense/greeting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {};

0 comments on commit c1e4b55

Please sign in to comment.