Skip to content
Merged
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
25 changes: 24 additions & 1 deletion projects/internals/eslint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,15 @@ import { browserTypescriptConfig, libraryConfig, litConfig, jsonConfig } from '@
export default [...browserTypescriptConfig, ...libraryConfig, ...litConfig, ...jsonConfig];
```

Plugin namespaces (`local-typescript`, `local-html`, `local-css`, `local-json`) register the custom rules inside those configs. Projects do not opt in per rule.
Performance rules use a separate config. Spread `performanceConfig` to enable every performance rule for production TypeScript:

```js
import { browserTypescriptConfig, performanceConfig } from '@internals/eslint';

export default [...browserTypescriptConfig, ...performanceConfig];
```

Plugin namespaces (`local-typescript`, `local-performance`, `local-html`, `local-css`, `local-json`) register the custom rules inside those configs.

## Authoring a new rule

Expand Down Expand Up @@ -110,3 +118,18 @@ Applied to `package.json` files.

- **`no-missing-bundle-registration`**. A component that ships a `define.ts` must also appear in `src/bundle.ts` so the CDN bundle registers it.
- **`no-missing-bundle-test`**. Every component registered by `src/bundle.ts` must also appear in the Lighthouse direct-import benchmark. The required `lighthouseTestFile` option names the project's combined Lighthouse test file.

### Performance rules

The `performanceConfig` enables these rules as errors for production TypeScript files.

- **`prefer-direct-typed-array-iteration`**. Avoids `Array.from(typedArray)` copies before operations such as `some`, `find`, and `forEach` that do not transform the array.
- **`require-animation-frame-cleanup`**. Requires a class that stores an animation frame handle to cancel that handle.
- **`require-observer-disconnect`**. Requires observers stored on class fields to call `disconnect()`. The rule recognizes standard observer constructors and the repository's observer factory names.
- **`no-inline-gpu-upload-allocation`**. Flags typed arrays and other buffer sources constructed directly in WebGPU `writeBuffer` and `writeTexture` calls.
- **`no-hot-path-collection-allocation`**. Flags collection-producing array operations, collection constructors, `Array.from`, and array spread inside explicit or recognized renderer hot paths.
- **`no-hot-path-buffer-allocation`**. Flags typed arrays, `ArrayBuffer`, and `DataView` construction inside explicit or recognized renderer hot paths.
- **`no-gpu-upload-in-loop`**. Flags direct WebGPU queue uploads inside loops and collection callbacks so callers batch work before crossing the browser boundary.
- **`require-gpu-resource-cleanup`**. Requires owning classes to destroy buffers, textures, and query sets retained on class fields.

Add `@hotPath` to a function or method to opt it into hot-path allocation checks. The rules also treat methods beginning with `draw`, `prepare`, or `render` on classes whose names end in `Renderer` as hot paths.
58 changes: 58 additions & 0 deletions projects/internals/eslint/src/configs/performance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import noGpuUploadInLoop from '../local/no-gpu-upload-in-loop.js';
import noHotPathBufferAllocation from '../local/no-hot-path-buffer-allocation.js';
import noHotPathCollectionAllocation from '../local/no-hot-path-collection-allocation.js';
import noInlineGpuUploadAllocation from '../local/no-inline-gpu-upload-allocation.js';
import preferDirectTypedArrayIteration from '../local/prefer-direct-typed-array-iteration.js';
import requireAnimationFrameCleanup from '../local/require-animation-frame-cleanup.js';
import requireGpuResourceCleanup from '../local/require-gpu-resource-cleanup.js';
import requireObserverDisconnect from '../local/require-observer-disconnect.js';

const source = ['src/**/*.ts', 'src/**/*.tsx'];
const ignores = [
'**/*.examples.ts',
'**/*.test*.ts',
'**/*.test*.tsx',
'build/',
'coverage/',
'dist/',
'node_modules/'
];

/**
* Enables performance rules for production TypeScript. Consumers may override
* individual severities in a later flat-config entry after spreading this config.
*
* @type {import('eslint').Linter.Config[]}
*/
export const performanceConfig = [
{
plugins: {
'local-performance': {
rules: {
'no-gpu-upload-in-loop': noGpuUploadInLoop,
'no-hot-path-buffer-allocation': noHotPathBufferAllocation,
'no-hot-path-collection-allocation': noHotPathCollectionAllocation,
'no-inline-gpu-upload-allocation': noInlineGpuUploadAllocation,
'prefer-direct-typed-array-iteration': preferDirectTypedArrayIteration,
'require-animation-frame-cleanup': requireAnimationFrameCleanup,
'require-gpu-resource-cleanup': requireGpuResourceCleanup,
'require-observer-disconnect': requireObserverDisconnect
}
}
}
},
{
files: source,
ignores,
rules: {
'local-performance/no-gpu-upload-in-loop': 'error',
'local-performance/no-hot-path-buffer-allocation': 'error',
'local-performance/no-hot-path-collection-allocation': 'error',
'local-performance/no-inline-gpu-upload-allocation': 'error',
'local-performance/prefer-direct-typed-array-iteration': 'error',
'local-performance/require-animation-frame-cleanup': 'error',
'local-performance/require-gpu-resource-cleanup': 'error',
'local-performance/require-observer-disconnect': 'error'
}
}
];
24 changes: 24 additions & 0 deletions projects/internals/eslint/src/configs/performance.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { performanceConfig } from './performance.js';

test('enables performance rules for production TypeScript', () => {
assert.equal(performanceConfig.length, 2);
const [pluginConfig, ruleConfig] = performanceConfig;
assert.deepEqual(ruleConfig.files, ['src/**/*.ts', 'src/**/*.tsx']);
assert.equal(ruleConfig.ignores.includes('**/*.test*.ts'), true);
assert.deepEqual(Object.keys(pluginConfig.plugins['local-performance'].rules).sort(), [
'no-gpu-upload-in-loop',
'no-hot-path-buffer-allocation',
'no-hot-path-collection-allocation',
'no-inline-gpu-upload-allocation',
'prefer-direct-typed-array-iteration',
'require-animation-frame-cleanup',
'require-gpu-resource-cleanup',
'require-observer-disconnect'
]);
assert.equal(
Object.values(ruleConfig.rules).every(severity => severity === 'error'),
true
);
});
1 change: 1 addition & 0 deletions projects/internals/eslint/src/configs/typescript.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ const config = {
tags: { name: true },
aria: { name: true },
experimental: { name: true },
hotPath: { name: false },
alpha: { name: true },
beta: { name: true },
stable: { name: true },
Expand Down
1 change: 1 addition & 0 deletions projects/internals/eslint/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export { jsonConfig } from './configs/json.js';
export { libraryConfig } from './configs/library.js';
export { appConfig } from './configs/app.js';
export { litConfig } from './configs/lit.js';
export { performanceConfig } from './configs/performance.js';
export { browserTypescriptConfig, nodeTypescriptConfig } from './configs/typescript.js';
export { cssConfig } from './configs/css.js';
90 changes: 90 additions & 0 deletions projects/internals/eslint/src/local/no-gpu-upload-in-loop.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
const ITERATION_METHODS = new Set([
'every',
'filter',
'find',
'findIndex',
'flatMap',
'forEach',
'map',
'reduce',
'reduceRight',
'some'
]);
const LOOP_NODES = new Set(['DoWhileStatement', 'ForInStatement', 'ForOfStatement', 'ForStatement', 'WhileStatement']);

/** @type {import('eslint').Rule.RuleModule} */
export default {
meta: {
type: 'suggestion',
name: 'no-gpu-upload-in-loop',
docs: {
description: 'Flags direct WebGPU queue uploads repeated by loops or collection callbacks.',
category: 'Performance',
recommended: false
},
schema: [],
messages: {
'repeated-upload':
'`{{method}}` runs inside repeated iteration. Batch uploads or merge dirty ranges before crossing the WebGPU boundary.'
}
},
create(context) {
return {
CallExpression(node) {
const method = getUploadMethod(node.callee);
if (method && isRepeated(node)) {
context.report({ node, messageId: 'repeated-upload', data: { method } });
}
}
};
}
};

function getUploadMethod(callee) {
if (callee.type !== 'MemberExpression' || !isGpuQueue(callee.object)) return null;
const name = callee.computed
? callee.property.type === 'Literal' && typeof callee.property.value === 'string'
? callee.property.value
: undefined
: callee.property.type === 'Identifier'
? callee.property.name
: undefined;
return name === 'writeBuffer' || name === 'writeTexture' ? name : null;
}

function isGpuQueue(node) {
if (node.type === 'Identifier') return /queue$/iu.test(node.name);
if (node.type !== 'MemberExpression') return false;
if (!node.computed && node.property.type === 'Identifier') return /queue$/iu.test(node.property.name);
return node.property.type === 'Literal' && typeof node.property.value === 'string'
? /queue$/iu.test(node.property.value)
: false;
}

function isRepeated(node) {
let current = node.parent;
while (current) {
if (LOOP_NODES.has(current.type)) return true;
if (isFunction(current)) return isIterationCallback(current);
current = current.parent;
}
return false;
}

function isFunction(node) {
return (
node.type === 'ArrowFunctionExpression' || node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression'
);
}

function isIterationCallback(node) {
const call = node.parent;
if (call?.type !== 'CallExpression' || !call.arguments.includes(node)) return false;
const callee = call.callee;
return (
callee.type === 'MemberExpression' &&
!callee.computed &&
callee.property.type === 'Identifier' &&
ITERATION_METHODS.has(callee.property.name)
);
}
55 changes: 55 additions & 0 deletions projects/internals/eslint/src/local/no-gpu-upload-in-loop.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import { beforeEach, test } from 'node:test';
import { RuleTester } from 'eslint';
import tseslint from 'typescript-eslint';
import noGpuUploadInLoop from './no-gpu-upload-in-loop.js';

let tester;

beforeEach(() => {
tester = new RuleTester({
languageOptions: { parser: tseslint.parser, parserOptions: { ecmaVersion: 'latest', sourceType: 'module' } }
});
});

test('defines rule metadata', () => {
assert.equal(noGpuUploadInLoop.meta.type, 'suggestion');
assert.equal(noGpuUploadInLoop.meta.name, 'no-gpu-upload-in-loop');
assert.ok(noGpuUploadInLoop.meta.messages['repeated-upload']);
});

test('valid: accepts single uploads and separately declared callbacks', () => {
tester.run('no-gpu-upload-in-loop', noGpuUploadInLoop, {
valid: [
{ code: `queue.writeBuffer(buffer, 0, bytes);` },
{ code: `for (const value of values) writer.writeBuffer(value);` },
{
code: `
const upload = bytes => queue.writeBuffer(buffer, 0, bytes);
for (const bytes of batches) schedule(upload, bytes);
`
}
],
invalid: []
});
});

test('invalid: reports queue uploads in loops and collection callbacks', () => {
tester.run('no-gpu-upload-in-loop', noGpuUploadInLoop, {
valid: [],
invalid: [
{
code: `for (const range of ranges) queue.writeBuffer(buffer, range.offset, range.bytes);`,
errors: [{ messageId: 'repeated-upload', data: { method: 'writeBuffer' } }]
},
{
code: `images.forEach(image => queue.writeTexture(destination, image, layout, size));`,
errors: [{ messageId: 'repeated-upload', data: { method: 'writeTexture' } }]
},
{
code: `while (pending()) queue['writeBuffer'](buffer, 0, next());`,
errors: [{ messageId: 'repeated-upload', data: { method: 'writeBuffer' } }]
}
]
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { isHotPath, walk } from './utils.js';

const BUFFER_CONSTRUCTORS = new Set([
'ArrayBuffer',
'BigInt64Array',
'BigUint64Array',
'DataView',
'Float32Array',
'Float64Array',
'Int8Array',
'Int16Array',
'Int32Array',
'SharedArrayBuffer',
'Uint8Array',
'Uint8ClampedArray',
'Uint16Array',
'Uint32Array'
]);

/** @type {import('eslint').Rule.RuleModule} */
export default {
meta: {
type: 'suggestion',
name: 'no-hot-path-buffer-allocation',
docs: {
description: 'Flags buffer and view construction in annotated and renderer hot paths.',
category: 'Performance',
recommended: false
},
schema: [],
messages: {
'buffer-constructor':
'`new {{kind}}` allocates storage or a view inside a hot path. Reuse owned scratch storage or move initialization outside the repeated path.'
}
},
create(context) {
return {
FunctionDeclaration(node) {
if (isHotPath(context, node)) inspectHotPath(context, node.body);
},
MethodDefinition(node) {
if (isHotPath(context, node)) inspectHotPath(context, node.value.body);
},
PropertyDefinition(node) {
if (isFunction(node.value) && isHotPath(context, node)) inspectHotPath(context, node.value.body);
},
VariableDeclaration(node) {
if (!isHotPath(context, node)) return;
for (const declaration of node.declarations) {
if (isFunction(declaration.init)) inspectHotPath(context, declaration.init.body);
}
}
};
}
};

function isFunction(node) {
return (
node?.type === 'ArrowFunctionExpression' ||
node?.type === 'FunctionDeclaration' ||
node?.type === 'FunctionExpression'
);
}

function inspectHotPath(context, body) {
walk(body, node => {
if (isFunction(node)) return false;
if (
node.type === 'NewExpression' &&
node.callee.type === 'Identifier' &&
BUFFER_CONSTRUCTORS.has(node.callee.name)
) {
context.report({ node, messageId: 'buffer-constructor', data: { kind: node.callee.name } });
Comment thread
coryrylan marked this conversation as resolved.
}
});
}
Loading