Common Gotchas
Issues you'll hit while working on memrynote, and the canonical fixes.
better-sqlite3 NODE_MODULE_VERSION Mismatch
Symptom: ERR_DLOPEN_FAILED, NODE_MODULE_VERSION X but expecting Y.
Two fix paths depending on the target:
| Target | Fix |
|---|---|
| Node tests | pnpm rebuild better-sqlite3 (or bash apps/desktop/scripts/ensure-native.sh node) |
| Electron app / E2E | bash apps/desktop/scripts/ensure-native.sh electron (or pnpm rebuild:electron) |
Using the Node fix for Electron leaves
autoOpenLastVaultsilently failing withERR_DLOPEN_FAILED. The app never opens the test vault, and E2E waits for workspace surfaces time out.
Electron binary re-downloads on every worktree
bash apps/desktop/scripts/ensure-native.sh electron (and the E2E fixtures, when path.txt is missing) fetch the ~115 MB Electron release zip from GitHub releases. Every fresh worktree pays that again, and the download is the single most common reason the install fails outright: curl: (56) Connection died, tried 5 times before giving up.
Set MEMRY_ELECTRON_CACHE_DIR to reuse one copy across worktrees:
export MEMRY_ELECTRON_CACHE_DIR="$HOME/.cache/memry-electron"The installer stores the verified zip there and, on later runs, restores it instead of downloading. Restored artifacts are not trusted: each one is hashed against the checksums.json shipped inside the electron npm package before extraction, and a mismatch deletes the entry and falls back to a normal download. Leaving the variable unset keeps the previous always-download behaviour.
Desktop CI sets this automatically via .github/actions/cache-electron-binary, keyed by the locked Electron version plus the runner's OS and arch.
Electron major upgrade — native ABI + V8 API removals
Bumping the electron major (e.g. 39 → 43) is more than a version change:
- Rebuild both native targets. Every
.nodeaddon (better-sqlite3,keytar,classic-level) must be rebuilt for the new ABI, and the Node-test ABI differs from the Electron-runtime ABI (see the NODE_MODULE_VERSION gotcha above). A Node rebuild is not proof for the Electron runtime, or vice-versa. node-abican lag the release.@electron/rebuildfails withCould not detect abi for version <X>when its transitivenode-abipredates the new Electron; the repo'sminimumReleaseAgegate can also holdnode-abiat an older version. Fix: pinnode-abiinpnpm-workspace.yamloverridesto a version that maps the target Electron, and add that version tominimumReleaseAgeExclude.- V8 API removals only surface in E2E. V8 15 (Electron 43) removed the legacy
Intl.Locale#textInfogetter; reading it threw at renderer boot → a blank white window. Vitest cannot catch this (Node's V8 still exposes the old shape) — only E2E on the bundled Electron proves the renderer boots. Feature-detect new APIs (e.g.getTextInfo()) and keep a fallback.
Release verification checklist (E43 and every future major)
The biggest residual risk of a major bump is the auto-update transition from the previous major's production build. CI cannot exercise a real prod→new-major update, so run these by hand before promoting the draft release:
- Real auto-update dry run from the previous prod major → new major, per platform:
- macOS, both arches. A real E39-prod x64 install and a real E39-prod arm64 install must each self-update. electron-updater's
MacUpdaterpicks thefiles[]zip whoseurlincludesarm64for arm64 hosts and the non-arm64 zip for x64 hosts, so both arch zips must survive the per-arch → mergedlatest-mac.ymlstep. That merge is now guarded byscripts/validate-mac-update-manifest.mjs(fails the release if either arch'surl/sha512is missing), but still confirm a live update on both arches. - Windows (NSIS) via
latest.yml— one prod install updates through the differential path (.blockmappresent next to the.exe). - Linux (AppImage) via
latest-linux.yml.
- macOS, both arches. A real E39-prod x64 install and a real E39-prod arm64 install must each self-update. electron-updater's
- Packaged native smoke green on all 3 platforms.
apps/desktop/scripts/check-packaged-runtime-deps.jsruns in the build jobs, but also open each downloaded artifact and confirm noERR_DLOPEN_FAILEDonbetter-sqlite3/keytar/classic-level(native ABI must match the new Electron runtime, not the Node-test ABI). - macOS notarization.
notarize: true(inapps/desktop/config/electron-builder.yml) must succeed and staple; a fresh download opens without Gatekeeper prompts. - Blockmaps present alongside every installer/zip in the release assets (delta updates depend on them).
- Stage the first new-major release. Roll it out to a canary slice and watch update-error telemetry before making it
latestfor everyone — the prod→new-major hop is the one thing CI can't prove.
Zod v4
z.record(z.unknown()) throws in safeParse under Zod v4. Use:
z.record(z.string(), z.unknown())This caught Phase 3 sync schemas — a few places still use the old form on legacy branches.
Drizzle Nullable JSON Columns
Drizzle's .values() insert distinguishes null from undefined. For nullable JSON columns, pass null explicitly:
db.insert(tasks).values({
id,
fieldClocks: null, // ← required for nullable JSON
...
})Passing undefined produces an INSERT that omits the column, then SQLite errors on NOT NULL columns or returns wrong rows.
Migrations Are Hand-Written Since 0020
pnpm db:generate proposes unrelated renames because Drizzle's meta snapshots stop at 0020. Hand-write the SQL and journal entry instead of running the generator.
Workflow:
- Update the schema in
packages/db-schema. - Add a new migration file (
migrations/00xx_description.sql). - Append a journal entry in
migrations/meta/_journal.json. - Run
pnpm db:pushto apply.
Submit Buttons That Disable Mid-Click
If onClick calls a handler that synchronously sets state which adds disabled={isSubmitting} to the button, the browser suppresses the click between pointerdown and click. The user thinks they clicked but nothing happens.
Fix: fire submit from onPointerDown. Keep onClick as a keyboard-activation fallback:
<button
onPointerDown={() => void submit()}
onClick={() => void submit()} // keyboard / accessibility fallback
disabled={isSubmitting}
>
Save
</button>See calendar-quick-create-dialog.tsx for the canonical version.
Anchor Rects From Virtualized Scrollers Are Not Viewport Coordinates
The calendar week grid is an infinitely virtualized day strip: the grid element is roughly 4.5 million pixels wide, and once it is scrolled to today its own left edge sits about 2.6 million pixels outside the window. Using that element's rect as a popover anchor — for example as a fallback when the day column element cannot be measured — places the popover far off-screen. It still reports as visible and enabled, so a click on it retries until the test or the user gives up.
Two rules when anchoring floating UI on a virtualized grid:
- Anchor on the column element, and derive the column offset (
gridRect.x + columnIndex * columnWidth) when that element is not measurable. Never collapse onto the strip's own left edge. - Clamp the computed position into the viewport on both axes, so a bad anchor can never push an action row out of reach.
computePopoverPosition in popover-position.ts owns the clamp for every calendar popover (task, note, event, inbox-snooze, quick-create).
Editor-Zone Mousedown Handlers Steal Focus from BlockNote Menus
BlockNote's shadcn menus (drag-handle menu, side menu, toolbars and their nested dropdowns) render inline inside .bn-container, not portaled. Any editor-zone mousedown handler — such as the "click the marquee zone to focus the editor at end" handler in note.tsx / journal.tsx, or the marquee selection hook — therefore also sees clicks on menu items. If such a handler focuses the editor on mousedown, the menu unmounts between pointerdown and pointerup, so the item's click never lands and the action silently does nothing (for example, drag-handle Colors/Delete appear to do nothing).
Fix: bail before touching focus when the target is inside menu UI:
if (
target.closest(
'.bn-side-menu, .bn-formatting-toolbar, .bn-suggestion-menu, .bn-link-toolbar, .bn-drag-handle-menu, .bn-menu-dropdown, [role="menu"]'
)
)
returnThis mirrors shouldStartMarquee in components/note/content-area/marquee-hit-test.ts. Regression coverage: tests/e2e/editor-drag-handle-menu.e2e.ts.
Global Keydown Listeners Must Not Depend on Render State
useKeyboardShortcuts (hooks/use-keyboard-shortcuts-base.ts), useChordShortcuts and useInboxKeyboard each bind exactly one window keydown listener per mount. The handler reads the shortcut list — and the tab/inbox state it acts on — from a ref refreshed after every render, so it always sees fresh values without re-registering.
Keep it that way when editing these hooks:
- Do not put render-derived values (shortcut arrays, tab state, list items, callbacks) in the registration effect's dependency array. Every tab open/close/switch and every inbox refetch would then detach and reattach the listener.
- Do not close over that state inside the registered listener either. Read it through the ref, or the shortcut acts on the state from the first render.
- Callers may keep building a fresh shortcut array on every render; the hook absorbs the churn.
Timers and rAF Handles Scheduled from Callbacks
A setTimeout fired from an event handler or a useCallback has no owner: the handle is unreachable, so nothing can cancel it. The pending callback keeps its closure — and every value that closure captured — alive until it fires, then runs setState on a component that may already be gone.
Use useTrackedTimeout (hooks/use-tracked-timeout.ts) for delayed work scheduled from a callback. It returns a stable (callback, delayMs) => void that remembers each pending handle and clears the set on unmount:
const scheduleTimeout = useTrackedTimeout()
scheduleTimeout(() => setCopied(false), 2000)Two cases stay hand-rolled:
- A timer created inside an effect belongs to that effect — hold it in an effect-scoped variable and
clearTimeoutit in the effect's own cleanup, so it also dies when the effect re-runs, not just on unmount. - A timer already tracked in a ref (a debounce that each new call replaces) only needs the missing unmount cleanup:
useEffect(() => () => clearTimeout(ref.current), []).
requestAnimationFrame has the same rule: keep the id and cancelAnimationFrame it in the cleanup. A queued auto-focus frame that survives its own teardown will steal focus from whatever replaced it.
Regression coverage: hooks/timer-raf-cleanup.test.tsx asserts vi.getTimerCount() === 0 after unmount, which only passes when the handle was really cleared.
Cross-Platform Env Vars in package Scripts
VAR=value cmd is POSIX-only. pnpm runs package scripts through cmd on Windows, where MEMRY_ENV=production pnpm ... fails with 'MEMRY_ENV' is not recognized. This broke the Windows release build (apps/desktop build script). Use cross-env for any inline env var that must work on Windows too:
"build": "cross-env MEMRY_ENV=production pnpm typecheck && cross-env MEMRY_ENV=production electron-vite build"The macOS and Linux release builds run scripts via sh, so the bug only surfaces in the Windows release job.
Lazy URL Resolution in http-client
The HTTP client resolves URLs per-call, not at module-import time. This avoids tests crashing on import when env vars are absent. If you add a new client, follow the same pattern: read env inside the function, not in module scope.
Pre-Existing Type Errors
These files have known type errors unrelated to runtime behavior. Ignore them when running pnpm typecheck:
apps/desktop/src/main/sync/websocket.test.tsapps/desktop/src/main/folders/folders.test.tsapps/desktop/src/main/sync/sync-telemetry.ts
For non-contract changes, use pnpm typecheck:node && pnpm typecheck:web to skip the flaky ipc:check pre-hook and the pre-existing sync-telemetry.ts error.
Virtualized UI Tests
@tanstack/react-virtual + jsdom = zero items rendered (because jsdom doesn't compute scroll heights). Cover virtualized calendar, week-view, and long-list UIs at the Playwright E2E layer only.
CRDT Sign-Out / Sign-In Ordering
When working in apps/desktop/src/main/sync/runtime.ts:
engine.start() # pull from server FIRST
└─ seedExistingCrdtDocs() # fire-and-forget, only fills orphansReversing the order causes split brain. See CRDT & Notes Sync for full reasoning.
Logging
Always use createLogger('Scope') from electron-log — never console.*. A pre-commit hook flags raw console.* calls.
DevTools Startup
The desktop app does not open DevTools automatically in development or production. Open them manually from the View menu or with the Electron DevTools shortcut when debugging startup.
User-Facing Errors
Always strip Electron IPC noise from error messages before display:
import { extractErrorMessage } from '@/lib/ipc-error'
toast.error(extractErrorMessage(err, 'Could not save note'))RTL-Safe Tailwind
New code must use logical properties (ms-*, pe-*, start-*, text-start, border-s, rounded-s-*) instead of physical ones (ml-*, pr-*, left-*, text-left, border-l, rounded-l-*). The lint config allows physical classes only in pre-existing files.
The staged renderer guard scans whole staged renderer files, not just new hunks. If you touch a file that still has physical direction classes, convert those nearby classes to logical equivalents before committing.
Security Scan Patterns
GitHub code scanning and the local staged-secret hook are intentionally conservative. When fixing or adding security-sensitive code:
- Compare URL hosts through
new URL(...).hostnameor a DOM anchor fallback, notstring.includes(). - Write generated files and vault payloads through exclusive temporary files plus
rename, not predictable temp paths. - Use
mkdtempfor tests that need temporary directories. - Keep log output sanitized. Do not print signing paths, API responses with headers, or raw error objects that may include request data.
- Invoke package-manager CLIs through a resolved Node/Corepack entry point instead of relying on
PATH. - For generated TypeScript, prefer data tables plus runtime assembly over interpolating dynamic keys into code snippets.
- In fixtures, avoid object fields named
token,secret, orapiKeywhen the value is runtime data. Use a neutral field name and keep the real header name only at the request boundary.
scripts/check-staged-secrets.mjs treats an assignment as credential-shaped only when a sensitive keyword (SECRET, TOKEN, PASSWORD, API_KEY, …) is a whole word in the key: ACCESS_TOKEN, refreshToken, and APIToken are scanned, while identifiers that merely contain one inside a longer word — fts5's tokenize='porter unicode61', tokenizer, passwordless — are not. scripts/check-staged-secrets.test.mjs covers both directions and runs in the Secret scan CI job; extend it when you change the rules rather than reformatting the code that trips them.
Pre-Production Database
memrynote is pre-production and the DB schema is resettable. There are no backward-compat constraints on schema changes within the desktop app. If a migration is messy, deleting the local vault is a valid recovery.