Skip to content

Commit ec7e648

Browse files
committed
Implement further PR review feedback from Claude from a comprehensive review across performance, security, maintenance, etc
1 parent 61bb8cf commit ec7e648

File tree

5 files changed

+138
-28
lines changed

5 files changed

+138
-28
lines changed

__tests__/components/ui/coaching-sessions/coaching-notes/coaching-notes-markdown-extensions.test.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,89 @@ describe('Coaching Notes Markdown Extensions', () => {
112112
expect(bodyCells.length).toBe(8)
113113
})
114114
})
115+
116+
it('should handle malformed markdown tables gracefully', async () => {
117+
const TestEditor = () => {
118+
const editor = useEditor({
119+
extensions: Extensions(yDoc, null),
120+
content: '',
121+
})
122+
123+
if (!editor) return null
124+
125+
// Missing separator row - should not crash
126+
const malformedTable = `| Name | Age |
127+
| Alice | 30 |`
128+
129+
editor.commands.insertContent(malformedTable, { contentType: 'markdown' })
130+
131+
return <EditorContent editor={editor} />
132+
}
133+
134+
const { container } = render(<TestEditor />)
135+
136+
// Should not crash - might render as paragraph or incomplete table
137+
await waitFor(() => {
138+
expect(container).toBeTruthy()
139+
})
140+
})
141+
142+
it('should handle tables with leading spaces', async () => {
143+
const TestEditor = () => {
144+
const editor = useEditor({
145+
extensions: Extensions(yDoc, null),
146+
content: '',
147+
})
148+
149+
if (!editor) return null
150+
151+
const tableWithSpaces = ` | Name | Age |
152+
| --- | --- |
153+
| Alice | 30 |`
154+
155+
editor.commands.insertContent(tableWithSpaces, { contentType: 'markdown' })
156+
157+
return <EditorContent editor={editor} />
158+
}
159+
160+
const { container } = render(<TestEditor />)
161+
162+
await waitFor(() => {
163+
const table = container.querySelector('table')
164+
expect(table).toBeTruthy()
165+
166+
const headerCells = container.querySelectorAll('th')
167+
expect(headerCells.length).toBe(2)
168+
})
169+
})
170+
171+
it('should handle empty table cells', async () => {
172+
const TestEditor = () => {
173+
const editor = useEditor({
174+
extensions: Extensions(yDoc, null),
175+
content: '',
176+
})
177+
178+
if (!editor) return null
179+
180+
const emptyTable = `| Name | Age |
181+
| --- | --- |
182+
| | |`
183+
184+
editor.commands.insertContent(emptyTable, { contentType: 'markdown' })
185+
186+
return <EditorContent editor={editor} />
187+
}
188+
189+
const { container } = render(<TestEditor />)
190+
191+
await waitFor(() => {
192+
const table = container.querySelector('table')
193+
expect(table).toBeTruthy()
194+
195+
const bodyCells = container.querySelectorAll('td')
196+
expect(bodyCells.length).toBe(2)
197+
})
198+
})
115199
})
116200
})

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"clsx": "^2.1.1",
6666
"cmdk": "^1.0.4",
6767
"date-fns": "^2.28.0",
68+
"just-debounce-it": "^3.2.0",
6869
"lucide-react": "^0.469.0",
6970
"next": "15.4.7",
7071
"next-themes": "^0.4.6",

src/components/ui/coaching-sessions/coaching-notes/extended-link-extension.tsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import Link from "@tiptap/extension-link";
22
import { Extension, markInputRule, markPasteRule } from "@tiptap/core";
33
import type { Editor } from "@tiptap/react";
4+
import debounce from "just-debounce-it";
5+
6+
/** Number of characters to search before and after cursor position when looking for zombie links */
7+
const ZOMBIE_LINK_SEARCH_RANGE = 100;
48

59
/**
610
* Triggers the link creation flow by setting an empty href,
@@ -91,7 +95,9 @@ export const LinkKeyboardShortcut = Extension.create({
9195

9296
// Extension to clean up empty/zombie links automatically
9397
// This ensures consistent behavior whether link was created via button or keyboard
94-
export const LinkZombieCleanup = Extension.create({
98+
export const LinkZombieCleanup = Extension.create<{
99+
debouncedCleanup?: (...args: any[]) => void;
100+
}>({
95101
name: "linkZombieCleanup",
96102

97103
onCreate() {
@@ -114,8 +120,8 @@ export const LinkZombieCleanup = Extension.create({
114120
let foundEmptyLink = false;
115121

116122
// Search around cursor position for empty links
117-
const searchFrom = Math.max(0, from - 100);
118-
const searchTo = Math.min(state.doc.content.size, to + 100);
123+
const searchFrom = Math.max(0, from - ZOMBIE_LINK_SEARCH_RANGE);
124+
const searchTo = Math.min(state.doc.content.size, to + ZOMBIE_LINK_SEARCH_RANGE);
119125

120126
state.doc.nodesBetween(searchFrom, searchTo, (node, pos) => {
121127
if (foundEmptyLink) return false;
@@ -146,8 +152,20 @@ export const LinkZombieCleanup = Extension.create({
146152
}
147153
};
148154

149-
this.editor.on("selectionUpdate", cleanupEmptyLinks);
150-
this.editor.on("transaction", cleanupEmptyLinks);
155+
// Debounce cleanup to reduce performance impact during rapid editor changes
156+
// 50ms delay is imperceptible while preventing excessive DOM searches
157+
this.options.debouncedCleanup = debounce(cleanupEmptyLinks, 50);
158+
159+
this.editor.on("selectionUpdate", this.options.debouncedCleanup);
160+
this.editor.on("transaction", this.options.debouncedCleanup);
161+
},
162+
163+
onDestroy() {
164+
// Clean up event listeners to prevent memory leaks
165+
if (this.options.debouncedCleanup) {
166+
this.editor.off("selectionUpdate", this.options.debouncedCleanup);
167+
this.editor.off("transaction", this.options.debouncedCleanup);
168+
}
151169
},
152170
});
153171

src/components/ui/coaching-sessions/coaching-notes/markdown-table-extension.tsx

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Token } from "marked";
2+
import { marked } from "marked";
23
import type {
34
MarkdownParseHelpers,
45
MarkdownRendererHelpers,
@@ -82,16 +83,6 @@ function isTableToken(token: unknown): token is TableToken {
8283
);
8384
}
8485

85-
/** Type guard to check if a cell is a table header cell */
86-
function isTableHeaderCell(cell: TableCellNode): cell is TableHeaderCell {
87-
return cell.type === 'tableHeader';
88-
}
89-
90-
/** Type guard to check if a cell is a table body cell */
91-
function isTableBodyCell(cell: TableCellNode): cell is TableBodyCell {
92-
return cell.type === 'tableCell';
93-
}
94-
9586
const createHeaderCell = (
9687
cell: TableCellToken,
9788
helpers: MarkdownParseHelpers
@@ -232,7 +223,7 @@ const renderTableMarkdown = (
232223
const headerRow = rows[0];
233224

234225
// Render header row and separator
235-
if (headerRow?.content) {
226+
if (headerRow?.content && headerRow.content.length > 0) {
236227
markdown += renderHeaderRow(headerRow, helpers);
237228
markdown += renderSeparatorRow(createColumnCount(headerRow.content.length));
238229
}
@@ -273,20 +264,29 @@ export const TableMarkdownPasteHandler = Extension.create({
273264
const text = event.clipboardData?.getData("text/plain");
274265
if (!text) return false;
275266

276-
// Check if the pasted text looks like a markdown table
277-
// Construct regex from parts to avoid Tailwind CSS picking it up
278-
const pipeChar = "|";
279-
const tablePattern = new RegExp(
280-
`\\${pipeChar}.*\\${pipeChar}.*\\n\\${pipeChar}[\\-:\\s${pipeChar}]+\\${pipeChar}`
281-
);
282-
const hasMarkdownTable = tablePattern.test(text);
267+
// Use marked's tokenizer to detect tables (handles leading whitespace correctly)
268+
const hasMarkdownTable = (() => {
269+
try {
270+
const tokens = marked.lexer(text);
271+
return tokens.some(token => token.type === 'table');
272+
} catch {
273+
// If tokenization fails, fall back to default paste
274+
return false;
275+
}
276+
})();
283277

284278
if (hasMarkdownTable && this.editor.markdown) {
285-
// Parse as markdown and insert
286-
this.editor.commands.insertContent(text, {
287-
contentType: "markdown",
288-
});
289-
return true; // Prevent default paste
279+
try {
280+
// Parse as markdown and insert
281+
this.editor.commands.insertContent(text, {
282+
contentType: "markdown",
283+
});
284+
return true; // Prevent default paste
285+
} catch (error) {
286+
console.error("Failed to paste markdown table:", error);
287+
// Fall through to default paste behavior
288+
return false;
289+
}
290290
}
291291

292292
return false; // Allow default paste for non-table content

0 commit comments

Comments
 (0)