.ai/CONVENTIONS.md
Files:
camelCase.js (e.g., hiddenColumns.js, conditionCollection.js, editorManager.js)camelCase/ (e.g., src/plugins/hiddenColumns/, src/plugins/copyPaste/)camelCase.js in src/helpers/ (e.g., array.js, object.js, unicode.js)*.unit.js for Jest unit tests, *.spec.js for Jasmine E2E tests*.types.ts in handsontable/test/types/index.js barrel that re-exports PLUGIN_KEY, PLUGIN_PRIORITY, and the classFunctions:
camelCase for all functions and methods: getActiveEditor(), createSpreadsheetData()# prefix (JavaScript private class fields): #onAfterDocumentKeyDown()row and columns (not cols)jsdoc/require-jsdoc: 'error')handsontableMethodFactory(), handsontableMouseTriggerFactory()Variables:
camelCase for all variables: activeEditor, tableMeta, cellPropertieshot for Handsontable instance references throughout the codebasethis.hot.rootWindow and this.hot.rootDocument instead of global window or document (enforced by ESLint rule no-restricted-globals)UPPER_SNAKE_CASE (e.g., PLUGIN_KEY, PLUGIN_PRIORITY, SETTING_KEYS, BROWSERS_LIST)Types:
PascalCase (e.g., BasePlugin, HiddenColumns, CellMeta, EditorManager)@type {TypeName} or @private tags.d.ts files use PascalCase for types and interfaces.ts files in handsontable/src/ -- core is JavaScript onlyFormatting:
airbnb-base)SwitchCase: 1, function params aligned to first)'string' not "string")it() test names matching ^\s*x?it\s*\()function foo() not function foo ())curly: ['error', 'all'])comma-dangle: 'off')++/-- except in for loop afterthoughtsno-eq-null: 'error' -- never use == null or != nullPadding/Blank Lines (enforced by padding-line-between-statements):
returnif, for, switch, while) unless preceded by another block-like statementconst, let, var) unless followed by another declarationLinting:
@babel/eslint-parser and JSX support.eslintrc.js (extends airbnb-base)handsontable/.eslintrc.js (extends root, adds custom plugin rules)eslint-plugin-compat against browser targets from browser-targets.jseslint-plugin-jsdoc at error levelstylelint --cache "src/**/*.{css,scss}" "test/**/*.{css,scss}"| Rule | Enforcement |
|---|---|
handsontable/no-native-error-throw | Use throwWithCause() from src/helpers/errors.js, never throw new Error() |
handsontable/restricted-module-imports | No imports from barrel index files (plugins/index, editors/index, renderers/index, validators/index, cellTypes/index, i18n/index). Import from specific submodule paths. Only exception: src/registry.js |
handsontable/require-async-in-it | All it() callbacks in *.spec.js must be async. Disabled for *.unit.js |
handsontable/require-await | Specific HOT API calls must be await-ed in *.spec.js (full list in handsontable/.eslintrc.js lines 84-151) |
no-restricted-globals | Source: window, document, console, Handsontable banned. Tests: only fit, fdescribe banned |
compat/compat | Browser API compatibility check (off in test files) |
Test file overrides (relaxed rules in handsontable/.eslintrc.js):
jsdoc/require-jsdoc: off in *.unit.js and *.spec.jshandsontable/no-native-error-throw: off in test fileshandsontable/require-async-in-it: off in *.unit.js (only enforced in *.spec.js)handsontable/require-await: off in *.unit.jsno-undef: off in test files (globals available from bootstrap)no-await-in-loop: off in test filesOrder:
../../helpers/)Example from src/plugins/hiddenColumns/hiddenColumns.js:
import { BasePlugin } from '../base';
import { addClass } from '../../helpers/dom/element';
import { rangeEach } from '../../helpers/number';
import { arrayEach, arrayMap, arrayReduce } from '../../helpers/array';
import { SEPARATOR } from '../contextMenu/predefinedItems';
import { Hooks } from '../../core/hooks';
import hideColumnItem from './contextMenuItem/hideColumn';
import showColumnItem from './contextMenuItem/showColumn';
import { HidingMap } from '../../translations';
Path Aliases (Jest/test only):
'handsontable' maps to <rootDir>/src'walkontable' maps to <rootDir>/src/3rdparty/walkontable/srcCritical Rule: No barrel imports in source code.
import { HiddenColumns } from '../plugins'import { HiddenColumns } from '../plugins/hiddenColumns/hiddenColumns'src/registry.js may import from barrel indicesPattern -- Always use throwWithCause():
import { throwWithCause } from '../helpers/errors';
// Instead of: throw new Error('message')
throwWithCause('The `fixedColumnsLeft` is not supported for RTL. Please use option `fixedColumnsStart`.');
Error Cause identification:
cause: { handsontable: true } for programmatic recognitionerror.cause?.handsontable === trueImplementation in src/helpers/errors.js:
export function throwWithCause(message) {
throw new Error(message, {
cause: { handsontable: true }
});
}
Framework: Custom wrappers in src/helpers/console.js
Available Functions:
log(...args) -- General loggingwarn(...args) -- Warning messagesdeprecatedWarn(message) -- Deprecated feature warnings (prefixed with "Deprecated: ")info(...args) -- Informational messageserror(...args) -- Error messagesUsage Pattern:
import { warn, deprecatedWarn } from './helpers/console';
warn('Both `rowHeights` and `minRowHeights` are defined. The `minRowHeights` will be ignored.');
deprecatedWarn('The `getTotalRows()` method is deprecated. Use `countRows()` instead.');
Why not console directly:
no-restricted-globals with custom error messageWhen to Comment:
// TODO: or // FIXME:)JSDoc/Typedoc Requirements:
jsdoc/require-jsdoc: 'error')@param {type} name - Description.@returns {type} Description.@private tagjsdoc/newline-after-description: 'error')JSDoc Template:
/**
* Brief description of the method.
*
* Additional notes if needed.
*
* @param {string} paramName - Description of the parameter.
* @param {number} anotherParam - Description.
*
* @fires [[eventName]] when triggered.
*
* @throws [[ErrorType]] when condition is met.
*
* @returns {string} Description of the return value.
*
* @category CategoryName
*/
Allowed Custom JSDoc Tags:
@plugin, @util, @experimental, @deprecated, @preserve, @core, @TODO, @category, @package, @template
Typedoc Formatting Rules:
[[MY_LINK]] syntax, not {@link MY_LINK}/** or above */Size: Aim for functions under 100 lines. Extract complex logic into helper functions in src/helpers/.
Parameters:
no-param-reassign is off, but prefer immutable patternsReturn Values:
consistent-return is off -- functions may return different types in branches@returns tagExports:
import/prefer-default-export: 'off')src/plugins/hiddenColumns/index.js:export {
PLUGIN_KEY,
PLUGIN_PRIORITY,
HiddenColumns,
} from './hiddenColumns';
Plugin Static Properties:
class MyPlugin extends BasePlugin {
static get PLUGIN_KEY() { return 'myPlugin'; }
static get PLUGIN_PRIORITY() { return 150; }
static get SETTING_KEYS() { return ['myPlugin']; }
static get DEFAULT_SETTINGS() { return {}; }
static get SETTINGS_VALIDATORS() { return null; }
static get PLUGIN_DEPS() { return ['plugin:AutoRowSize']; }
}
Plugin Lifecycle Methods (in order):
constructor(hotInstance) -- receives HOT instance as this.hotisEnabled() -- return truthy/falsy based on this.hot.getSettings()[PLUGIN_KEY]enablePlugin() -- set up hooks via this.addHook(), register IndexMapper maps. Call super.enablePlugin() at the endupdatePlugin() -- typical: this.disablePlugin(); this.enablePlugin(); super.updatePlugin();disablePlugin() -- Call super.disablePlugin() first (clears EventManager and hooks). Then clean updestroy() -- final teardown. Call super.destroy() at the endHooks Registration at Module Level (outside the class):
import { Hooks } from '../../core/hooks';
Hooks.getSingleton().register('beforeMyAction');
Hooks.getSingleton().register('afterMyAction');
Important: this.addHook() (BasePlugin method) auto-cleans hooks on disablePlugin(). this.hot.addHook() does not.
src/plugins/{pluginName}/
├── index.js # Re-exports PLUGIN_KEY, PLUGIN_PRIORITY, ClassName
├── {pluginName}.js # Main plugin class extending BasePlugin
├── __tests__/ # Tests (*.spec.js for E2E, *.unit.js for unit)
│ └── helpers/ # Optional plugin-specific test helpers (auto-loaded for E2E)
└── {submodules}/ # Additional subdirectories as needed
this.hot.rootWindow instead of global windowthis.hot.rootDocument instead of global documentconst element = this.hot.rootDocument.createElement('div');
const width = this.hot.rootWindow.innerWidth;
Use ?. only when a value is genuinely optional by design. Do not use as a blanket safety net. If a value is guaranteed by the data contract (e.g., parallel arrays from the same iterator, getCellMeta() always returns an object), access directly without ?.. Unnecessary optional chaining hides bugs.
arr.push(...largeArray) with arrays that could exceed 10k elements -- use forEach loopbatch() / batchRender() / suspendRender() / resumeRender() for multiple operations that trigger renderingrequestAnimationFrame for batching scroll eventsht-theme-main, ht-theme-classic, ht-theme-horizon (each with -no-icons variants)