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

Reactivity v2 #87

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
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
3 changes: 2 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ dist
./configs
node_modules
jest.config.js
jest.setup.js
jest.setup.js
bin
79 changes: 79 additions & 0 deletions bin/reactivity.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env node

const fs = require("fs/promises");
const path = require("path");
const glob = require("fast-glob");

const KEY = "solid/reactivity";
const WRAPPED_KEY = `"${KEY}"`;

async function findPlugins() {
const pluginsMap = new Map();

const files = await glob("**/package.json", { onlyFiles: true, unique: true, deep: 10 });
await Promise.all(
files.map(async (file) => {
const contents = await fs.readFile(file, "utf-8");

if (contents.includes(WRAPPED_KEY)) {
const parsed = JSON.parse(contents);
if (parsed && Object.prototype.hasOwnProperty.call(parsed, KEY)) {
const pluginPath = parsed[KEY];
if (file === "package.json") {
pluginsMap.set(".", pluginPath); // associate current CWD with the plugin path
} else {
pluginsMap.set(parsed.name, pluginPath); // associate the package name with the plugin path
}
}
}
})
);

return pluginsMap;
}

async function reactivityCLI() {
if (process.argv.some((token) => token.match(/\-h|\-v/i))) {
console.log(
`eslint-plugin-solid v${require("../package.json").version}

This CLI command searches for any plugins for the "solid/reactivity" ESLint rule that your dependencies make available.

If any are found, an ESLint rule config will be printed out with the necessary options to load and use those "solid/reactivity" plugins.
If you are authoring a framework or template repository for SolidJS, this can help your users get the best possible linter feedback.

For instructions on authoring a "solid/reactivity" plugin, see TODO PROVIDE URL.`
);
} else {
console.log(
`Searching for ${WRAPPED_KEY} keys in all package.json files... (this could take a minute)`
);
const pluginsMap = await findPlugins();
if (pluginsMap.size > 0) {
// create a resolve function relative from the root of the current working directory
const { resolve } = require("module").createRequire(path.join(process.cwd(), "index.js"));

const plugins = Array.from(pluginsMap)
.map(([packageName, pluginPath]) => {
const absPluginPath = resolve(
(packageName + "/" + pluginPath).replace(/(\.\/){2,}/g, "./")
);
return path.relative(process.cwd(), absPluginPath);
})
.filter((v, i, a) => a.indexOf(v) === i); // remove duplicates

const config = [1, { plugins }];

console.log(
`Found ${plugins.length} "solid/reactivity" plugin${plugins.length !== 1 ? "s" : ""}. Add ${
plugins.length !== 1 ? "them" : "it"
} to your ESLint config by adding the following to the "rules" section:\n`
);
console.log(`"solid/reactivity": ${JSON.stringify(config, null, 2)}`);
} else {
console.log(`No "solid/reactivity" plugins found.`);
}
}
}

reactivityCLI();
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
"is-html": "^2.0.0",
"kebab-case": "^1.0.2",
"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.21.3",
Expand Down
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

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

16 changes: 9 additions & 7 deletions scripts/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,13 +181,15 @@ async function run() {
const docRoot = path.resolve(__dirname, "..", "docs");
const docFiles = (await fs.readdir(docRoot)).filter((p) => p.endsWith(".md"));
for (const docFile of docFiles) {
markdownMagic(path.join(docRoot, docFile), {
transforms: {
HEADER: () => buildHeader(docFile),
OPTIONS: () => buildOptions(docFile),
CASES: (content: string) => buildCases(content, docFile),
},
});
if (docFile !== "reactivity-customization.md") {
markdownMagic(path.join(docRoot, docFile), {
transforms: {
HEADER: () => buildHeader(docFile),
OPTIONS: () => buildOptions(docFile),
CASES: (content: string) => buildCases(content, docFile),
},
});
}
}
}

Expand Down
7 changes: 3 additions & 4 deletions src/rules/reactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ export default createRule<Options, MessageIds>({
const { currentScope, parentScope } = scopeStack;

/** Tracks imports from 'solid-js', handling aliases. */
const { matchImport, handleImportDeclaration } = trackImports();
const { matchImport } = trackImports(sourceCode.ast);

/** Workaround for #61 */
const markPropsOnCondition = (node: FunctionNode, cb: (props: T.Identifier) => boolean) => {
Expand Down Expand Up @@ -627,7 +627,7 @@ export default createRule<Options, 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 Expand Up @@ -1144,7 +1144,6 @@ export default createRule<Options, MessageIds>({
};

return {
ImportDeclaration: handleImportDeclaration,
JSXExpressionContainer(node: T.JSXExpressionContainer) {
checkForTrackedScopes(node);
},
Expand All @@ -1156,7 +1155,7 @@ export default createRule<Options, MessageIds>({
checkForSyncCallbacks(node);

// ensure calls to reactive primitives use the results.
const parent = node.parent && ignoreTransparentWrappers(node.parent, true);
const parent = node.parent && ignoreTransparentWrappers(node.parent, "up");
if (parent?.type !== "AssignmentExpression" && parent?.type !== "VariableDeclarator") {
checkForReactiveAssignment(null, node);
}
Expand Down
101 changes: 101 additions & 0 deletions src/rules/reactivity/analyze.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { TSESLint, TSESTree as T } from "@typescript-eslint/utils";
import { ProgramOrFunctionNode, FunctionNode } from "../../utils";
import type { ReactivityPluginApi } from "./pluginApi";

interface TrackedScope {
/**
* The root node, usually a function or JSX expression container, to allow
* reactive variables under.
*/
node: T.Node;
/**
* The reactive variable should be one of these types:
* - "tracked-scope": synchronous function or signal variable, or JSX container/spread
* - "called-function": synchronous or asynchronous function like a timer or
* event handler that isn't really a tracked scope but allows reactivity
*/
expect: "tracked-scope" | "called-function";
}

export interface VirtualReference {
/**
* 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 {
return inner[0] >= outer[0] && inner[1] <= outer[1];
}

export class ReactivityScope {
constructor(
public node: ProgramOrFunctionNode,
public parentScope: ReactivityScope | null
) {}

childScopes: Array<ReactivityScope> = [];
trackedScopes: Array<TrackedScope> = [];
errorContexts: Array<T.Node> = [];
references: Array<VirtualReference> = [];
hasJSX = false;

deepestScopeContaining(node: T.Node | T.Range): ReactivityScope | null {
const range = Array.isArray(node) ? node : node.range;
if (isRangeWithin(range, this.node.range)) {
const matchedChildRange = this.childScopes.find((scope) =>
scope.deepestScopeContaining(range)
);
return matchedChildRange ?? this;
}
return null;
}

isDeepestScopeFor(node: T.Node | T.Range) {
return this.deepestScopeContaining(Array.isArray(node) ? node : node.range) === this;
}

*walk(): Iterable<ReactivityScope> {
yield this;
for (const scope of this.childScopes) {
yield* scope.walk();
}
}

private *iterateUpwards<T>(prop: (scope: ReactivityScope) => Iterable<T>): Iterable<T> {
yield* prop(this);
if (this.parentScope) {
yield* this.parentScope.iterateUpwards(prop);
}
}
}

// class ReactivityScopeTree {
// constructor(public root: ReactivityScope) {}
// syncCallbacks = new Set<FunctionNode>();

// /** Finds the deepest ReactivityScope containing a given range, or null if the range extends beyond the code length. */
// deepestScopeContaining = this.root.deepestScopeContaining.bind(this.root);
// // (node: T.Node | T.Range): ReactivityScope | null {
// // return this.root.deepestScopeContaining(range);
// // }

// }
87 changes: 87 additions & 0 deletions src/rules/reactivity/pluginApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { TSESTree as T, TSESLint, TSESTree } from "@typescript-eslint/utils";
import { FunctionNode, ProgramOrFunctionNode } from "../../utils";



export interface ReactivityPluginApi {
/**
* Mark a node as a function in which reactive variables may be polled (not tracked). Can be
* async.
*/
calledFunction(node: T.Node): void;
/**
* Mark a node as a tracked scope, like `createEffect`'s callback or a JSXExpressionContainer.
*/
trackedScope(node: T.Node): void;
/**
* Mark a node as a function that will be synchronously called in the current scope, like
* Array#map callbacks.
*/
syncCallback(node: FunctionNode): void;

/**
* Alter the error message within a scope to provide additional details.
*/
provideErrorContext(node: T.Node, errorMessage: string): void;

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

/**
* Mark that a node evaluates to a store or props. Short for `.reactive(node, (path ?? '') + '.**')`
*/
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, 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
* - `=`: defines the declaration scope, no other effect; implicitly current scope if not given,
* only one `=` is allowed
* @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: string, options?: { mutable?: boolean }): 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> | RegExp,
module?: string | RegExp
): node is T.CallExpression;
}

export interface ReactivityPlugin {
package: string;
version: string;
create: (api: ReactivityPluginApi) => TSESLint.RuleListener;
}

// Defeats type widening, could also use `satisfies ReactivityPlugin`
export function plugin(p: ReactivityPlugin): ReactivityPlugin {
return p;
}

// ===========================
Loading