Handsontable Core Development
Dispatch table
Load handsontable-code-review (architecture dimension) first for every change, then the task-specific specialist. It is the design lens (SOLID, Law of Demeter, plugin decoupling, breaking changes); the task skill is the implementation guide.
| Task | Skill |
|---|
| Create / modify a plugin | handsontable-plugin-dev |
| Create / modify an editor | handsontable-editor-dev |
| Create / modify a renderer | handsontable-renderer-dev |
| Create / modify a validator | handsontable-validator-dev |
| Create / modify a cell type | handsontable-celltype-dev |
| Write a new E2E test | handsontable-playwright-e2e (Playwright, tests/e2e/) |
Maintain a legacy E2E test (*.spec.js, frozen) | handsontable-e2e-testing |
Write / modify unit tests (*.unit.js or *.unit.ts) | handsontable-unit-testing |
| Make a test actually prove behavior | test-writing-discipline |
| Build a demo / test page | handsontable-demo-page |
| Work on CSS / themes | handsontable-css-dev |
| Walkontable rendering engine | walkontable-dev / walkontable-testing |
| A rendering-engine geometry change needs a spec (viewport, overlays, sizing, scroll sync) | engine tier: edit an existing walkontable Jasmine spec, or add new coverage in tests/e2e/walkontable/*.spec.ts (walkontable-dev, Testing) |
| Lint violations | handsontable/AGENTS.md (Lint) + handsontable/.ai/CONVENTIONS.md |
| Coordinate translation (physical/visual/renderable) | coordinate-systems |
| i18n / translations | i18n-translations |
| Visual regression tests | visual-testing |
| Docs pages | writing-docs-pages |
Design rules from handsontable-code-review
Full checklist: its references/architecture.md.
- Plugin decoupling. Talk via hooks; reach another plugin only through
hot.getPlugin('Name'). No circular plugin dependencies.
- Conflict ownership. The plugin that introduces an incompatibility owns the blocking logic. For hard conflicts, call
registerConflict() from src/plugins/base/conflictRegistry.ts at module load time.
- Law of Demeter. Go through
TableView or Core for Walkontable data, not this.hot.view.wt.wtTable chains.
- Liskov. Honor the full
BasePlugin contract (lifecycle methods + static properties).
- Cascading config. New options fit
cell → column → global where applicable; document table-level-only options (like data) in JSDoc.
- Breaking changes. Keep the legacy CSS class in the DOM when renaming. Keep old API names working (no warning for legacy, one-time warning for deprecated). Keep every default setting value. Put removed hooks/options on the removed list so misuse throws.
- Convention over configuration. Red flags: new options with the same value at every call site (make it the default), new directories outside the existing taxonomy, explicit wiring where auto-discovery works.
- Gold standard:
src/plugins/pagination/pagination.ts (plugin structure, settings validation, conflict registration, focus management).
File layout
src/plugins/{pluginName}/ index.ts, {pluginName}.ts, types.ts?, __tests__/,
AGENTS.md + CLAUDE.md->AGENTS.md (required)
src/editors/{editorName}/ index.ts, {editorName}.ts
src/renderers/{rendererName}/ index.ts, {rendererName}.ts
src/validators/{validatorName}/ index.ts, {validatorName}.ts
src/cellTypes/{typeName}/ index.ts, {typeName}.ts
Unit tests are *.unit.js or *.unit.ts. New E2E is Playwright (tests/e2e/*.spec.ts); the Jasmine *.spec.js suite is frozen (edit existing specs, migrate broken ones to Playwright). .d.ts files are generated by npm run build:types into handsontable/tmp/.
TypeScript gotchas
1. Generalize the signature, add zero as casts
New code adds no as casts (as SomeType, <SomeType>value). Treat the urge to write one as a wrong type upstream. Exhaust these in order: (1) make the function/method signature generic; (2) make the shared entity generic; (3) narrow with a type guard; (4) use unknown only at a true I/O boundary. A cast is acceptable only when none apply (external/normalization boundary, known TS generic-inference gap) and carries a comment saying why. as unknown as T (double cast) is banned. Use unknown instead of any at boundaries, then narrow. After removing casts, rerun npm run test:types; some are load-bearing.
// ✗ function getFirst(items: unknown[]): SomeRow { return items[0] as SomeRow; }
// ✓ function getFirst<T>(items: T[]): T { return items[0]; }
Fix the type at its source. When the same cast repeats across consumers, make the shared structure generic with a permissive default so existing callers compile unchanged:
class TreeNode<T extends object = Record<string, unknown>> {
data: T;
walkDown(cb: (node: TreeNode<T>) => unknown) { /* ... */ }
}
Example: src/utils/dataStructures/tree.ts TreeNode<T>, consumed by nestedHeaders as TreeNode<HeaderNodeData>.
1a. DOM narrowing
Use isHTMLElement from src/helpers/dom/element.ts (for a Node | Element | null value) instead of x as HTMLElement, x instanceof HTMLElement, or a nodeType === Node.ELEMENT_NODE guard.
2. .d.ts files
Declarations are generated from source into handsontable/tmp/ only: hand-write no .d.ts anywhere (there is no separate types/ mirror) and edit nothing under tmp/. If a type is missing from the public API, fix the JSDoc/export in the .ts source and rerun npm run build:types.
3. import type for types
import type { HotInstance } from '../../core/types';
4. Import shared types from core/
| Type | Location |
|---|
GridSettings, Events, HookKey | src/core/settings.ts |
HotInstance | src/core/types.ts |
| Plugin-local types | the plugin's own types.ts |
Import these types; never paste a partial copy of GridSettings/HotInstance into the file you are editing (a local copy drifts from the real signature). When the existing type is too wide, narrow it with a generic parameter or type guard at the boundary. Consumers import the same types from the package (import type { GridSettings } from 'handsontable'); see docs/content/guides/tools-and-building/typescript-types/typescript-types.md.
Adding a new hook:
- Add the callback signature to
GridSettings in src/core/settings.ts.
- Register the hook name in
src/core/hooks/constants.ts / src/core/hooks/index.ts.
- Call
Hooks.getSingleton().register('myHook') at module level in the plugin file.
5. Private fields use #, callbacks are arrow-function class fields
class MyPlugin extends BasePlugin {
#map: HidingMap | null = null;
#onAfterRender = (): void => { /* `this` is bound */ };
}
@private JSDoc tags and .bind(this) are forbidden.
6. Cognitive complexity ≤ 15 per function
Extract a helper to fix a violation.
Cross-cutting rules (enforced by ESLint)
| Rule | What to do |
|---|
No throw new Error() | throwWithCause('...', cause) from src/helpers/errors.ts |
No window / document / console globals | this.hot.rootWindow, this.hot.rootDocument, helpers from src/helpers/console.ts |
No raw setTimeout / setInterval | this.hot._registerTimeout(fn, delay) (auto-clears on hot.destroy()) |
| No barrel imports | Import from the specific submodule path (plugins/index, editors/index, renderers/index, validators/index, cellTypes/index, i18n/index are off limits). Only src/registry.ts may use barrels. |
| No direct cross-plugin imports | Hooks or this.hot.getPlugin('Name') |
it() in *.spec.js must be async | async with await when calling HOT rendering APIs |
No native toLocaleLowerCase(locale) | localeLowerCase(value, locale) from helpers/string (enforced by no-restricted-syntax) |
JSDoc
jsdoc/require-jsdoc is error for src/**/*.ts and scripts/**/*.mjs: every class, method, function declaration, and class field needs a block. Test and type files (*.unit.ts, *.spec.ts, *.types.ts, *.d.ts) are exempt.
- Multiline format (root
AGENTS.md), with a blank line before /** and after */.
{Type} in every @param and @returns, public or private. docs:api (jsdoc-to-markdown) reads compiled handsontable/tmp/ and prints these types in the API reference. Keep them in sync with the TS signature ({number|object} for number | { from: number; to: number }). The TS [80004] warning is suppressed for .ts source.
- The
{Type} must parse as JSDoc (catharsis), not TypeScript. A tuple breaks docs:api with Invalid type expression. Write @type {Array<number> | null} and keep the tuple on the TS annotation (#pastePlan: [number, number, number, number] | null). No PR check catches this: the docs path filter in .github/workflows/checks.yml covers docs/** and handsontable/package.json, not handsontable/src/**, so the break surfaces as a failing Docs Staging Deployment on the next unrelated push touching docs/**. After a JSDoc type change, build the package and run npm --prefix docs run docs:api.
- Private
# fields: a description block, no @private.
- Descriptions state something new (what the member does or represents, with population/invalidation rules where relevant), not a restatement of its name. American English, active voice, short sentences.
DOM and data gotchas
- Merged cells: read meta, not DOM. Read
colspan/rowspan from hot.getCellMeta(row, col), not td.colSpan / td.rowSpan. MergeCells sets cellProperties.colspan via afterGetCellMeta; the DOM attribute may be missing outside the viewport and ignores custom afterGetCellMeta overrides.
- Coordinates. Translate physical/visual/renderable with
hot.rowIndexMapper / hot.columnIndexMapper (coordinate-systems skill).
- i18n. Add constants to
src/i18n/constants.ts and translations to every file in src/i18n/languages/.
Registration wiring
| Component | Wire into |
|---|
| Plugin | src/plugins/index.ts |
| Editor | src/editors/index.ts + src/editors/factory.ts |
| Renderer | src/renderers/index.ts + src/renderers/factory.ts |
| Validator | src/validators/index.ts |
| Cell type | src/cellTypes/index.ts |
| Any new option / hook | src/dataMap/metaManager/metaSchema.ts + src/core/settings.ts |
Build: type declarations
npm run build:types # step 1: tsc emits tmp/**/*.d.ts
npm run downlevel:types # step 2: rewrites post-TS-5.1 lib types to TS 5.1 equivalents
npm run build runs both. Run after any change to the public type surface (new export, changed parameter type, new GridSettings option). Wrappers consume handsontable/tmp/.
The dev compiler is TS 6, which infers ArrayIterator, IteratorObject (TS 5.6+) and WeakKey (TS 5.2+), absent from TS 5.1. scripts/downlevel-dts.mjs swaps them for IterableIterator<T> / object. CI job verify-emitted-types runs tsc@5.1.6 --noEmit against tmp/ and reports the leaked identifier as TS2304: Cannot find name '...'. Fix by either annotating the source with the TS 5.1 type (: IterableIterator<T>, WeakMap<object, V>) or adding a row to REPLACEMENTS in scripts/downlevel-dts.mjs.
Mandatory checklist for every change