- Use TypeScript with strict mode enabled.
- Always write new React components as function components with hooks.
Do not add class components. The single permitted exception is an error boundary (
getDerivedStateFromError/componentDidCatchhave no hook equivalent);src/diagram/ErrorBoundary.tsxis the one class component in the codebase and new error boundaries may subclass it or follow the same shape. Everything else -- including imperative shells with subscriptions, timers, and async lifecycles -- is a function component. - Wrap render-hot components (e.g. per-element diagram pieces) in
React.memo, anduseCallbackany handler passed to a memo'd child so the memoization actually holds. - Converting a class component (or writing a function-component imperative shell), follow the established pattern used by
Canvas.tsxandEditor.tsx: instance fields move into a single mutablerefsobject (useRef); state that the old constructor derived from props uses a lazyuseState(() => ...)initializer so it runs once per mount; escaped callbacks (listeners, timers, async continuations) read current props/state through alatestref refreshed synchronously each render, never a stale render closure;componentDidMount/componentWillUnmountbecome one symmetric empty-deps mount effect whose cleanup undoes everything the body did, so a React 18 StrictMode mount/unmount/mount cycle leaks nothing; andcomponentDidUpdateprev-value comparisons become post-commit effects guarded by a prev-value ref so they keep "fire on change, not on mount" semantics. Objects that StrictMode's double-invokeduseStateinitializer must not construct twice (e.g. a resource that opens a handle) are built in arefs-init guard instead. - Use proper TypeScript types, avoid
any. - NEVER manually copy files around to get builds or tests passing. Identify the root cause and fix the build scripts.
- Target 95%+ code coverage for new code.
- Follow the functional core, imperative shell pattern to ensure as much logic as possible is in easily testable pure functions.
- Tests run on Rstest, configured per package in
rstest.config.mts.pnpm testruns them all;pnpm -C src/<pkg> exec rstest run <filter>runs a subset. - Test globals are off. Import what you use:
import { describe, it, expect, rs } from '@rstest/core';.rsis the mocking/timer namespace (rs.fn,rs.spyOn,rs.mock,rs.useFakeTimers, ...). Mock<T>'s type argument is the whole function signature, not the return type:rs.fn<(msg: WsMessage) => void>(). Jest's two-argumentjest.Mock<Return, Args>has no equivalent.- A
rs.mockfactory is hoisted above the imports, so it cannot close over one, and async factories are rejected. To keep part of the real module, pull it in withimport * as actual from 'mod' with { rstest: 'importActual' };-- the synchronous stand-in forjest.requireActual. Import attributes need an ES module target, so this cannot be used insrc/server(its program emits CommonJS and type-checks its own tests); reach forrs.mock('mod', { spy: true })there. - A
rs.mockfactory runs the first time the module under test imports the mocked specifier -- during the hoisted import phase, before any statement in the test file's body. So a shared mock a factory dereferences must be created withrs.hoisted(() => ...), which is lifted alongside thers.mockcalls; a plain module-scopeconstis still in its temporal dead zone and throwsReferenceErrorat module load. (Under jest, source-order CommonJS emit meant a plainconstworked.) Merely capturing a binding in a closure the factory returns is fine -- it is only read later, at call time -- but that indirection is easy to "simplify" back into a crash. Seesrc/app/tests/new-project.test.tsx. - Rstest has no
@jest-environmentdocblock. A package that needs both environments declares them asprojectsin its config (seesrc/diagram/rstest.config.mts). - jsdom +
@testing-library/reactpackages need a setup file: RTL only self-registers itsafterEach(cleanup)whenafterEachis a global, andwaitForonly drives fake timers when a globaljestobject exists. Seesrc/app/tests/setup-testing-library.ts. - Anything a module under test must observe while it is being imported belongs in a
setupFilesentry, never at the top of the test file. A module's imports are fully evaluated before its own top-level statements run, so aglobalThis.fetch = ...or aTextEncoderpolyfill written above the import lands after the imported module already ran. This is a trap carried over from jest, whose CommonJS emit keptrequire()calls in source order and so made the misplaced version work; rspack hoists ES imports, as the spec requires.src/app/tests/setup-fetch.ts(App.tsx fetches/api/userat module scope) andsrc/diagram/tests/setup-text-encoder.ts(the engine's memory module constructs aTextEncoderat import time) are the two live cases. resolve.aliasStrategy: 'prefer-alias'disables tsconfigpathsoutright -- it does not merely rankaliasabove them. Rsbuild wires rspack's tsconfig-paths resolver only under the default'prefer-tsconfig'. So in aprefer-aliasconfig, every@simlin/*specifier the tests should see as source needs an explicit alias; anything missed resolves through the package'sexportsmap to builtlib/output, where a stale build passes the tests.src/appandsrc/diagramalias@simlin/coreand@simlin/engineto source;src/coreandsrc/serverdeliberately alias them to builtlib/(so their suites need a priorpnpm build).- Test files are not type-checked (
isolatedModulesmakes the toolchain transpile-only). Tracked in #899.