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
59 changes: 59 additions & 0 deletions .cursor/skills/nvm-jest-v22/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
name: nvm-jest-v22
description: >-
Runs Jest in this repository using Node v22 via nvm, then npx jest for specific
test files or folders. Use when running tests in pie-lib, when Jest fails with
a Node syntax error (e.g. optional chaining in jest-cli), or when the user asks
to test specific files or packages with the correct Node version.
---

# nvm v22 + npx Jest (pie-lib)

## Required workflow

Use **nvm use v22** and then use **npx jest** to test specific files, folders.

Run commands from the **repository root** (`pie-lib`), where `jest.config.js` lives.

### One-off command shape

Load nvm if the shell is non-interactive (e.g. agent terminals may not source `.bashrc`):

```bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
cd /path/to/pie-lib
nvm use v22
npx jest <paths-or-patterns> [optional jest flags]
```

### Examples

Single test file:

```bash
npx jest packages/editable-html-tip-tap/src/components/__tests__/CharacterPicker.test.jsx
```

All tests under a package folder (Jest resolves paths under `packages/*/src` via `testRegex`):

```bash
npx jest packages/editable-html-tip-tap
```

Pattern:

```bash
npx jest --testPathPattern=MenuBar
```

Watch mode (when the user asks):

```bash
npx jest --watch packages/some-package/src/__tests__
```

## Notes

- If `nvm use v22` fails, ensure Node 22 is installed (`nvm install 22`).
- Prefer **npx jest** from repo root so the workspace `jest.config.js` and local `jest` version are used.
80 changes: 80 additions & 0 deletions packages/editable-html-tip-tap/src/__tests__/EditableHtml.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ describe('EditableHtml', () => {
const editorConfig = useEditor.mock.calls[useEditor.mock.calls.length - 1][0];
const blurEditor = {
getHTML: jest.fn(() => '<p>changed</p>'),
schema: {},
_insertingImage: true,
_toolbarOpened: false,
isActive: jest.fn(() => false),
Expand All @@ -350,6 +351,85 @@ describe('EditableHtml', () => {
jest.useRealTimers();
});

it('does not run blur onChange/onDone when editor has no schema', async () => {
jest.useFakeTimers();
const onChange = jest.fn();
const onDone = jest.fn();

render(
<EditableHtml
{...defaultProps}
markup="<p>Hello World</p>"
onChange={onChange}
onDone={onDone}
toolbarOpts={{ doneOn: 'blur' }}
/>,
);

await waitFor(() => {
expect(useEditor).toHaveBeenCalled();
});

const editorConfig = useEditor.mock.calls[useEditor.mock.calls.length - 1][0];
const getHTML = jest.fn(() => '<p>changed</p>');
const blurEditor = {
getHTML,
schema: undefined,
_insertingImage: false,
_toolbarOpened: false,
isActive: jest.fn(() => false),
};

editorConfig.onBlur({ editor: blurEditor });
jest.advanceTimersByTime(200);

expect(getHTML).not.toHaveBeenCalled();
expect(onChange).not.toHaveBeenCalled();
expect(onDone).not.toHaveBeenCalled();

jest.useRealTimers();
});

it('calls getHTML once on blur and passes the same html to onChange and onDone', async () => {
jest.useFakeTimers();
const onChange = jest.fn();
const onDone = jest.fn();
const html = '<p>from editor</p>';

render(
<EditableHtml
{...defaultProps}
markup="<p>Hello World</p>"
onChange={onChange}
onDone={onDone}
toolbarOpts={{ doneOn: 'blur' }}
/>,
);

await waitFor(() => {
expect(useEditor).toHaveBeenCalled();
});

const editorConfig = useEditor.mock.calls[useEditor.mock.calls.length - 1][0];
const getHTML = jest.fn(() => html);
const blurEditor = {
getHTML,
schema: {},
_insertingImage: false,
_toolbarOpened: false,
isActive: jest.fn(() => false),
};

editorConfig.onBlur({ editor: blurEditor });
jest.advanceTimersByTime(200);

expect(getHTML).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(html);
expect(onDone).toHaveBeenCalledWith(html);

jest.useRealTimers();
});

describe('onUpdate callback', () => {
it('calls onChange when transaction.isDone is true', async () => {
const onChange = jest.fn();
Expand Down
15 changes: 11 additions & 4 deletions packages/editable-html-tip-tap/src/components/CharacterPicker.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ export function CharacterPicker({ editor, opts, onClose }) {
}

const containerRef = useRef(null);
const onCloseRef = useRef(onClose);
const [position, setPosition] = useState({ top: 0, left: 0 });
const [popover, setPopover] = useState(null);

onCloseRef.current = onClose;

const configToUse = useMemo(() => {
if (!opts) return spanishConfig;

Expand Down Expand Up @@ -69,6 +72,9 @@ export function CharacterPicker({ editor, opts, onClose }) {
[],
);

// Keep `onClose` out of the dependency array — parents often pass a new callback each
// render (e.g. after each keystroke), which would re-run this effect constantly. Use a
// ref so click-outside always calls the latest close handler.
useEffect(() => {
if (!editor) return;

Expand All @@ -86,14 +92,15 @@ export function CharacterPicker({ editor, opts, onClose }) {
}

setPosition({
// top: start.top + Math.abs(bodyRect.top) - containerRef.current.offsetHeight - 10 + additionalTopOffset, // shift above
top: top,
left: start.left,
});

const editorViewDom = editor.view.dom;

const handleClickOutside = (e) => {
if (containerRef.current && !containerRef.current.contains(e.target) && !editor.view.dom.contains(e.target)) {
onClose();
if (containerRef.current && !containerRef.current.contains(e.target) && !editorViewDom.contains(e.target)) {
onCloseRef.current();
}
};

Expand All @@ -105,7 +112,7 @@ export function CharacterPicker({ editor, opts, onClose }) {
clearTimeout(timeoutId);
document.removeEventListener('click', handleClickOutside);
};
}, [editor, onClose]);
}, [editor]);

const renderPopOver = (event, el) => setPopover({ anchorEl: event.currentTarget, el });

Expand Down
10 changes: 6 additions & 4 deletions packages/editable-html-tip-tap/src/components/EditableHtml.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,16 +299,18 @@ export const EditableHtml = (props) => {
editor.isActive('inline_dropdown') ||
editor.isActive('explicit_constructed_response');

if (otherToolbarOpened) {
if (otherToolbarOpened || !editor.schema) {
return;
}

if (props.markup !== editor.getHTML()) {
props.onChange?.(editor.getHTML());
const html = editor.getHTML();

if (props.markup !== html) {
props.onChange?.(html);
}

if (toolbarOptsToUse.doneOn === 'blur') {
props.onDone?.(editor.getHTML());
props.onDone?.(html);
}
}, 200),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { act, fireEvent, render, waitFor } from '@testing-library/react';
import { CharacterIcon, CharacterPicker } from '../CharacterPicker';

jest.mock('react-dom', () => ({
Expand Down Expand Up @@ -128,13 +128,55 @@ describe('CharacterPicker', () => {
};
render(<CharacterPicker editor={mockEditor} opts={opts} onClose={onClose} />);

await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
fireEvent.click(document.body);

await waitFor(() => {
setTimeout(() => {
fireEvent.click(document.body);
}, 0);
expect(onClose).toHaveBeenCalled();
});
});

expect(onClose).toHaveBeenCalled();
it('does not re-run positioning when only onClose reference changes', async () => {
const opts = {
characters: [['á', 'é']],
};
const getRect = mockEditor.options.element.getBoundingClientRect;
const { rerender } = render(<CharacterPicker editor={mockEditor} opts={opts} onClose={jest.fn()} />);

await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});

const callsAfterMount = getRect.mock.calls.length;
expect(callsAfterMount).toBeGreaterThan(0);

rerender(<CharacterPicker editor={mockEditor} opts={opts} onClose={jest.fn()} />);

expect(getRect.mock.calls.length).toBe(callsAfterMount);
});

it('outside click invokes the latest onClose after rerender', async () => {
const opts = {
characters: [['á', 'é']],
};
const onCloseFirst = jest.fn();
const { rerender } = render(<CharacterPicker editor={mockEditor} opts={opts} onClose={onCloseFirst} />);

await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});

const onCloseSecond = jest.fn();
rerender(<CharacterPicker editor={mockEditor} opts={opts} onClose={onCloseSecond} />);

fireEvent.click(document.body);

await waitFor(() => {
expect(onCloseSecond).toHaveBeenCalled();
});
expect(onCloseFirst).not.toHaveBeenCalled();
});

it('does not close when clicking inside picker', async () => {
Expand Down
Loading