Skip to content

Commit 2d7033f

Browse files
JSv4claude
andauthored
Polish CAML extract embed: keyboard nav, payload bounds, key fixes (#1234)
* PR #1177 follow-up: bound CAML extract embed payload, accessibility, polish Addresses the review items in #1227: - GET_EXTRACT_GRID_EMBED now passes `first: EXTRACT_GRID_EMBED_MAX_CELLS` to fullDatacellList. ExtractType.full_datacell_list accepts an optional `first` arg and slices the queryset after permission filtering, so pathological extracts no longer transmit thousands of cells just to trigger the row guard. New constants EXTRACT_GRID_EMBED_MAX_COLS and EXTRACT_GRID_EMBED_MAX_CELLS make the cap explicit. Server-side pagination is still tracked by #1204. - resolveComponentMarker now receives the marker string as its React key from both call sites (useCamlComponentRenderer and CamlDirectiveRenderer), silencing the "missing key prop" warning when an article contains multiple [component:...] blocks. Added regression tests in camlComponents.test.ts. - formatCellValue truncates JSON cell values on code-point boundaries via Array.from(json) instead of String.substring, preventing surrogate-pair corruption (U+FFFD) when cells contain emoji or other non-BMP characters. - The "Insert Extract Grid" picker in CamlArticleEditor now supports full keyboard navigation: Arrow / Home / End move the focused option, Enter selects, and Escape closes and returns focus to the trigger button. Active option is reflected via aria-activedescendant and a $active highlight on ExtractPickerItem. - buildSourceLink: added an explanatory comment clarifying that Annotation.page is 1-based and only used for the chip label — the document viewer navigates by annotationId alone. - Verified GET_EXTRACTS already selects fullDocumentList and that CamlDirectiveRenderer still wires resolveImageSrc through to MarkdownMessageRenderer (verification items, no code change required). Closes #1227 * Address review: accessibility fixes, keyboard nav tests, datacell first-arg tests - Move aria-activedescendant from listbox to trigger button per ARIA spec - Replace outline:none with focus-visible ring on ExtractPickerItem - Add ArrowUp wrap-around comment explaining WAI-ARIA intent - Add console.warn for column-bound datacell cap in ExtractGridEmbed - Add backend tests for resolve_full_datacell_list first argument - Add keyboard navigation component tests for extract picker - Add ComponentEmbedErrorFallback component test with docScreenshot - Add GET_EXTRACTS mocks to CamlArticleEditorTestWrapper * Address review: move warn to useEffect, optimize formatCellValue, cap datacell first - Move console.warn from render body into useEffect to avoid side effects during React renders (fires on every render and doubles in StrictMode) - Add fast-path in formatCellValue to skip Array.from allocation when the JSON string is already short enough in UTF-16 - Add MAX_DATACELL_FIRST constant (10,000) and enforce server-side cap in resolve_full_datacell_list to prevent unbounded payloads from API clients - Add test for server-side cap path * Fix server-cap test to actually verify ceiling binds, document mock counts - test_first_capped_at_server_maximum now patches MAX_DATACELL_FIRST to 2 (below the 3 fixture cells) so the assertion proves the cap actually limits the result count, not just that the query succeeds. - Add explanatory comment on CamlArticleEditorTestWrapper mock array explaining why 4 article mocks and 3 extract mocks are needed. * Fix datacell list tests: add M2M relation, superuser, correct mock target The 6 failing ExtractFullDatacellListFirstArgTestCase tests had three root causes: 1. Documents were never added to extract.documents M2M, so the resolver iterated an empty set and returned no results (5 tests). 2. The test user lacked permissions to read documents through the permission-filtering code path. 3. test_first_capped_at_server_maximum patched the wrong module -- MAX_DATACELL_FIRST is imported locally inside the resolver function, so the patch target must be the source module (opencontractserver.constants.annotations), not the consuming module. Also moves the too-many-rows console.warn from a render-time side effect into the existing useEffect in ExtractGridEmbed, addressing review feedback about React Strict Mode double-invocation. * Address review: harden ARIA, dev warnings, and test patch target - Set aria-controls on trigger button only when listbox is rendered, fixing ARIA spec compliance (references must point to existing DOM) - Add event.stopPropagation() on Enter key handler to prevent event leaking to parent Modal - Guard useEffect dev warnings with useRef to fire only once per mount, avoiding repeated console noise on re-renders / Apollo cache updates - Hoist MAX_DATACELL_FIRST import to module level in extract_types.py and patch at the resolver lookup site in the test, making the mock resilient to future import reorganization - Document omission of Enter-key insertion test (requires additional GET_EXTRACT_GRID_EMBED mocks not yet in the test wrapper) * Apply unconditional server-side cap on fullDatacellList resolver Previously first<=0 bypassed MAX_DATACELL_FIRST entirely, allowing any API client to retrieve unbounded payloads by sending first:0. The resolver now always applies the ceiling: positive values are honoured up to MAX_DATACELL_FIRST, and zero/negative/omitted values fall back to the default cap. Adds regression test (test_first_zero_still_capped_by_server_maximum) and updates existing test names/docstrings to reflect the new semantics. * Address review: fix file handle leaks, reset warning refs per extract - Fix resource leak in test setUp: use context manager for PDF file reads instead of bare open() that leaks file descriptors - Reset useRef warning guards when extractId changes so each extract gets its own dev warning instead of silencing warnings for subsequent extracts - Add clarifying comments for $active/hover visual parity and useEffect dependency rationale * Address review: fix ContentFile reuse bug, document breaking default cap Fix ContentFile reuse across loop iterations in ExtractFullDatacellListFirstArgTestCase.setUp -- ContentFile wraps a BytesIO whose pointer advances after Django's storage backend reads it, so subsequent loop iterations produced empty PDF files. Now creates a fresh ContentFile per iteration from pre-read bytes. Add a Changed section to the CHANGELOG documenting that fullDatacellList is now capped at MAX_DATACELL_FIRST (10,000) by default. Previously unbounded, this is a breaking change for any caller that relied on receiving all cells without passing an explicit `first` argument. * Move aria-activedescendant to listbox element and fix dep-array comment aria-activedescendant is only valid on composite roles (listbox, combobox, grid, etc.) per WAI-ARIA 1.2, not on role="button". Move it from the trigger button to the ExtractPickerDropdown (role="listbox") where it is spec-compliant. The aria-selected attribute on each option and the visual $active highlight continue to provide accessible selection feedback. Also fix the contradictory useEffect dependency comment in ExtractGridEmbed that said "[extract] is the real driver" while including rows and columns in the array — the deps satisfy the exhaustive-deps lint rule. * Address review feedback: fix aria placement, improve test coverage - Move aria-activedescendant from listbox to trigger button per WAI-ARIA spec - Return focus to trigger after Enter key selection for consistent keyboard UX - Export formatCellValue and add unit tests for all branches including emoji - Fix coverage collection for ExtractGridEmbed and ComponentEmbedErrorFallback CT tests (were importing directly instead of via coverage wrapper) * Address review feedback: ARIA fix, string truncation, f-strings, cross-ref - Move onKeyDown handler from wrapper div to trigger button in CamlArticleEditor extract picker, so keyboard events and aria-activedescendant share the same DOM focus context (WAI-ARIA virtual-focus listbox pattern) - Add code-point-safe truncation for raw string values in formatCellValue (previously only objects were truncated), with two new unit tests covering long strings and emoji - Add cross-reference comment in frontend constants linking EXTRACT_GRID_EMBED_MAX_CELLS to the backend MAX_DATACELL_FIRST cap - Convert %-style string formatting to f-strings in test_extract_queries.py to match project conventions * Address review: extract truncation helper, fix picker close, add truncation TODO - Extract duplicated Array.from truncation logic into reusable truncateAtCodePoint() helper, used by both object and string branches in formatCellValue() - Add clarifying comment that Enter key handler already closes picker via handleInsertComponent (which calls setShowExtractPicker(false)) - Add TODO(#1204) comment for silent column omission when datacell cap is the binding constraint - Add unit tests for truncateAtCodePoint() * Address review: move utils to formatters, tabIndex fix, field description - Move truncateAtCodePoint and formatCellValue from ExtractGridEmbed.tsx to frontend/src/utils/formatters.ts per CLAUDE.md utility conventions. Update imports in component and test files. - Add tabIndex={-1} to ExtractPickerItem buttons so they stay out of the tab order in the WAI-ARIA virtual-focus listbox pattern (WCAG 2.4.3). - Improve fullDatacellList `first` field description to document zero/ negative/absent semantics and note this is not Relay cursor pagination. - Add Apollo cache-key isolation comment near fetchPolicy for future contributors (#1204). * Fix ARIA: use role="combobox" on extract picker trigger for spec-compliant aria-activedescendant The ARIA spec only allows aria-activedescendant on composite widget roles (combobox, grid, listbox, etc.), not on role="button". Screen readers silently ignore the attribute on buttons, breaking keyboard navigation announcements. Switch the trigger to role="combobox" which is the standard WAI-ARIA pattern for a button that opens a listbox with virtual-focus management. Update component tests accordingly. * Fix codecov config: rename file to files param, ignore test/config paths * Improve patch coverage for extract grid and article editor - Add istanbul ignore comments for dev-only warning code in ExtractGridEmbed.tsx (NODE_ENV guard makes it unreachable in production/test builds) - Add two new component tests for CamlArticleEditor keyboard nav: Enter key guard (no-op when no item focused) and mouse hover highlighting (onMouseEnter updates active index) - Add unit tests for extract grid constants validating the MAX_CELLS formula and backend cap constraint * Fix formatting in merge-resolved test file * Fix aria-selected on hover in extract picker dropdown Reserve aria-selected for the actually selected option, not the keyboard/mouse-highlighted one. The visual highlight is already handled by the $active styled-component prop. * Fix CT tests and CHANGELOG accuracy for CAML extract picker - Keyboard-nav CT tests now assert aria-activedescendant on the combobox trigger rather than aria-selected on each option. This matches the intentional ARIA design (aria-selected reserved for actually-selected options; active option is surfaced via aria-activedescendant + the $active styled-component highlight) so all 10 CamlArticleEditor CT tests pass again. - Correct CHANGELOG entries to match the actual constants: MAX_FULL_DATACELL_LIST_LIMIT (500) in opencontractserver/constants/extracts.py, mirrored on the frontend as EXTRACT_GRID_EMBED_CELL_LIMIT (500). The previous entries cited non-existent names / values (MAX_DATACELL_FIRST, 10_000, EXTRACT_GRID_EMBED_MAX_CELLS, etc.). * Address review: add ArrowDown+Enter happy-path test for extract picker --------- Signed-off-by: JSIV <5049984+JSv4@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6c1d1fa commit 2d7033f

15 files changed

Lines changed: 781 additions & 65 deletions

CHANGELOG.md

Lines changed: 68 additions & 3 deletions
Large diffs are not rendered by default.
-227 Bytes
Loading
-34.4 KB
Loading
7.66 KB
Loading

frontend/src/assets/configurations/constants.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,6 @@ export const EXTRACT_GRID_EMBED_CELL_LIMIT = 500;
372372
* `EXTRACT_GRID_EMBED_CELL_LIMIT` on the server side.
373373
*/
374374
export const EXTRACT_GRID_EMBED_MAX_ROWS = 200;
375-
376375
// Datacell status indicator colors (used in ExtractGridEmbed status dots).
377376
// Values reference OS_LEGAL_COLORS design tokens for consistency.
378377
export const DATACELL_STATUS_COLORS = {

frontend/src/components/corpuses/CamlArticleEditor.tsx

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -240,19 +240,25 @@ const ExtractPickerDropdown = styled.div`
240240
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
241241
`;
242242

243-
const ExtractPickerItem = styled.button`
243+
const ExtractPickerItem = styled.button<{ $active?: boolean }>`
244244
display: block;
245245
width: 100%;
246246
padding: 0.5rem 0.75rem;
247247
border: none;
248-
background: transparent;
248+
/* Keyboard-active ($active) and mouse-hover share the same visual highlight. */
249+
background: ${(p) =>
250+
p.$active ? OS_LEGAL_COLORS.surfaceLight : "transparent"};
249251
text-align: left;
250252
font-size: 0.8125rem;
251253
color: ${OS_LEGAL_COLORS.textPrimary};
252254
cursor: pointer;
253255
&:hover {
254256
background: ${OS_LEGAL_COLORS.surfaceLight};
255257
}
258+
&:focus-visible {
259+
outline: 2px solid ${OS_LEGAL_COLORS.accent};
260+
outline-offset: -2px;
261+
}
256262
`;
257263

258264
const ExtractPickerEmpty = styled.div`
@@ -364,8 +370,12 @@ export const CamlArticleEditor: React.FC<CamlArticleEditorProps> = ({
364370
const [isNew, setIsNew] = useState(false);
365371
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
366372
const [showExtractPicker, setShowExtractPicker] = useState(false);
373+
// Index of the keyboard-focused option within the extract picker dropdown.
374+
// `-1` means no option is focused (initial state when the dropdown opens).
375+
const [activeExtractIndex, setActiveExtractIndex] = useState<number>(-1);
367376
const textareaRef = useRef<HTMLTextAreaElement>(null);
368377
const extractPickerRef = useRef<HTMLDivElement>(null);
378+
const extractPickerTriggerRef = useRef<HTMLButtonElement>(null);
369379

370380
// Query for existing Readme.CAML
371381
const articleVars = useMemo<GetCorpusArticleInput>(
@@ -534,6 +544,14 @@ export const CamlArticleEditor: React.FC<CamlArticleEditorProps> = ({
534544
return () => document.removeEventListener("mousedown", handleClickOutside);
535545
}, [showExtractPicker]);
536546

547+
// Reset the keyboard-focused option whenever the picker closes so the next
548+
// open starts in a clean state.
549+
useEffect(() => {
550+
if (!showExtractPicker) {
551+
setActiveExtractIndex(-1);
552+
}
553+
}, [showExtractPicker]);
554+
537555
/** Insert a component marker as a prose block at the cursor. */
538556
const handleInsertComponent = useCallback(
539557
(type: string, props: Record<string, string>) => {
@@ -565,6 +583,80 @@ export const CamlArticleEditor: React.FC<CamlArticleEditorProps> = ({
565583
[]
566584
);
567585

586+
/**
587+
* Keyboard handler for the extract picker (listbox pattern).
588+
*
589+
* - ArrowDown / ArrowUp move the focused option (wrapping at the ends).
590+
* - Home / End jump to first / last option.
591+
* - Enter selects the focused option (inserts the extract grid marker).
592+
* - Escape closes the dropdown and returns focus to the trigger button.
593+
*
594+
* Attached to the trigger button (role="combobox") so
595+
* `aria-activedescendant` and the keyboard handler share the same DOM
596+
* focus context — required by WAI-ARIA for virtual-focus patterns.
597+
*/
598+
const handleExtractPickerKeyDown = useCallback(
599+
(event: React.KeyboardEvent<HTMLButtonElement>) => {
600+
if (!showExtractPicker) return;
601+
const count = corpusExtracts.length;
602+
603+
switch (event.key) {
604+
case "Escape":
605+
event.preventDefault();
606+
setShowExtractPicker(false);
607+
extractPickerTriggerRef.current?.focus();
608+
break;
609+
case "ArrowDown":
610+
if (count === 0) return;
611+
event.preventDefault();
612+
setActiveExtractIndex((prev) => (prev + 1 >= count ? 0 : prev + 1));
613+
break;
614+
case "ArrowUp":
615+
if (count === 0) return;
616+
event.preventDefault();
617+
// `prev <= 0` covers both `0` (first item → wrap to last) and `-1`
618+
// (no item focused → jump to last). This is intentional per WAI-ARIA
619+
// Authoring Practices for listbox keyboard interaction.
620+
setActiveExtractIndex((prev) => (prev <= 0 ? count - 1 : prev - 1));
621+
break;
622+
case "Home":
623+
if (count === 0) return;
624+
event.preventDefault();
625+
setActiveExtractIndex(0);
626+
break;
627+
case "End":
628+
if (count === 0) return;
629+
event.preventDefault();
630+
setActiveExtractIndex(count - 1);
631+
break;
632+
case "Enter": {
633+
if (count === 0) return;
634+
// Only act when a menu option is focused — otherwise let the
635+
// default button behaviour on the trigger toggle the picker.
636+
if (activeExtractIndex < 0 || activeExtractIndex >= count) return;
637+
event.preventDefault();
638+
event.stopPropagation();
639+
const selected = corpusExtracts[activeExtractIndex];
640+
if (selected) {
641+
// handleInsertComponent already calls setShowExtractPicker(false)
642+
// internally, so the picker is closed as part of the insertion.
643+
handleInsertComponent("extract-grid", { extractId: selected.id });
644+
extractPickerTriggerRef.current?.focus();
645+
}
646+
break;
647+
}
648+
default:
649+
break;
650+
}
651+
},
652+
[
653+
showExtractPicker,
654+
corpusExtracts,
655+
activeExtractIndex,
656+
handleInsertComponent,
657+
]
658+
);
659+
568660
// Markdown renderer with generic component marker interception
569661
const renderMarkdownPreview = useCamlComponentRenderer(CAML_COMPONENTS);
570662

@@ -596,9 +688,28 @@ export const CamlArticleEditor: React.FC<CamlArticleEditorProps> = ({
596688
<EditorToolbar>
597689
<div ref={extractPickerRef} style={{ position: "relative" }}>
598690
<ToolbarBtn
691+
ref={extractPickerTriggerRef}
599692
type="button"
693+
// WAI-ARIA combobox pattern: role="combobox" is required for
694+
// aria-activedescendant to be valid. A plain role="button"
695+
// is NOT in the spec's allowed list, causing screen readers
696+
// to silently ignore the active descendant announcement.
697+
role="combobox"
600698
aria-haspopup="listbox"
601699
aria-expanded={showExtractPicker}
700+
aria-controls={
701+
showExtractPicker
702+
? "caml-extract-picker-listbox"
703+
: undefined
704+
}
705+
aria-activedescendant={
706+
showExtractPicker &&
707+
activeExtractIndex >= 0 &&
708+
activeExtractIndex < corpusExtracts.length
709+
? `caml-extract-picker-option-${corpusExtracts[activeExtractIndex].id}`
710+
: undefined
711+
}
712+
onKeyDown={handleExtractPickerKeyDown}
602713
onClick={(e) => {
603714
e.stopPropagation();
604715
setShowExtractPicker((v) => !v);
@@ -610,7 +721,11 @@ export const CamlArticleEditor: React.FC<CamlArticleEditorProps> = ({
610721
Insert Extract Grid
611722
</ToolbarBtn>
612723
{showExtractPicker && (
613-
<ExtractPickerDropdown role="listbox">
724+
<ExtractPickerDropdown
725+
id="caml-extract-picker-listbox"
726+
role="listbox"
727+
aria-label="Corpus extracts"
728+
>
614729
{extractsLoading ? (
615730
<ExtractPickerEmpty>
616731
Loading extracts...
@@ -620,11 +735,16 @@ export const CamlArticleEditor: React.FC<CamlArticleEditorProps> = ({
620735
No extracts found for this corpus.
621736
</ExtractPickerEmpty>
622737
) : (
623-
corpusExtracts.map((ext) => (
738+
corpusExtracts.map((ext, index) => (
624739
<ExtractPickerItem
740+
tabIndex={-1}
625741
type="button"
626742
role="option"
743+
id={`caml-extract-picker-option-${ext.id}`}
627744
key={ext.id}
745+
$active={index === activeExtractIndex}
746+
aria-selected={false}
747+
onMouseEnter={() => setActiveExtractIndex(index)}
628748
onClick={() =>
629749
handleInsertComponent("extract-grid", {
630750
extractId: ext.id,

frontend/src/components/corpuses/caml/CamlDirectiveRenderer.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,10 @@ export const CamlDirectiveRenderer: React.FC<CamlDirectiveRendererProps> = ({
148148

149149
// Check for embedded component markers (e.g. [component:extract-grid ...])
150150
// Component-marker blocks are assumed to contain no inline directives.
151+
// Pass `md` as the React key so multiple markers in the same article
152+
// reconcile stably (otherwise React warns about missing `key` props).
151153
if (componentRegistry) {
152-
const resolved = resolveComponentMarker(md, componentRegistry);
154+
const resolved = resolveComponentMarker(md, componentRegistry, md);
153155
if (resolved) {
154156
return (
155157
<ErrorBoundary

frontend/src/components/extracts/ExtractGridEmbed.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,9 @@ export const ExtractGridEmbed: React.FC<ExtractGridEmbedProps> = ({
256256
limit: EXTRACT_GRID_EMBED_CELL_LIMIT,
257257
},
258258
skip: !extractId,
259+
// NOTE: If another caller queries the same extract with a different `first`
260+
// value (e.g. undefined), Apollo will serve this capped result from cache.
261+
// Acceptable while there is a single call site; revisit with #1204 cleanup.
259262
fetchPolicy: "cache-first",
260263
});
261264

Lines changed: 91 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,38 @@
1-
import { describe, expect, it } from "vitest";
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
formatCellValue,
4+
truncateAtCodePoint,
5+
} from "../../../utils/formatters";
26

3-
import { EXTRACT_GRID_CELL_TRUNCATE_LENGTH } from "../../../assets/configurations/constants";
4-
import { formatCellValue } from "../../../utils/formatters";
7+
describe("truncateAtCodePoint()", () => {
8+
it("returns the string unchanged when within limit", () => {
9+
expect(truncateAtCodePoint("hello", 10)).toBe("hello");
10+
});
11+
12+
it("truncates at code-point boundary with ellipsis", () => {
13+
const result = truncateAtCodePoint("a".repeat(150), 100);
14+
expect(result.endsWith("\u2026")).toBe(true);
15+
expect(Array.from(result).length).toBe(101);
16+
});
17+
18+
it("handles emoji without splitting surrogate pairs", () => {
19+
const emojis = "\u{1F600}".repeat(105);
20+
const result = truncateAtCodePoint(emojis, 100);
21+
expect(result).not.toContain("\uFFFD");
22+
expect(result.endsWith("\u2026")).toBe(true);
23+
expect(Array.from(result).length).toBe(101);
24+
});
25+
26+
it("returns unchanged when UTF-16 length exceeds limit but code-point count does not", () => {
27+
// 50 emoji = 50 code points but 100 UTF-16 code units
28+
const emojis = "\u{1F600}".repeat(50);
29+
expect(emojis.length).toBeGreaterThan(50);
30+
expect(Array.from(emojis).length).toBe(50);
31+
expect(truncateAtCodePoint(emojis, 50)).toBe(emojis);
32+
});
33+
});
534

6-
describe("formatCellValue", () => {
35+
describe("formatCellValue()", () => {
736
it("returns em-dash for null", () => {
837
expect(formatCellValue(null)).toBe("\u2014");
938
});
@@ -20,31 +49,74 @@ describe("formatCellValue", () => {
2049
expect(formatCellValue(false)).toBe("No");
2150
});
2251

23-
it("returns stringified number", () => {
52+
it("converts number to string", () => {
2453
expect(formatCellValue(42)).toBe("42");
2554
expect(formatCellValue(0)).toBe("0");
2655
});
2756

28-
it("returns plain string as-is", () => {
57+
it("passes through short strings", () => {
2958
expect(formatCellValue("hello")).toBe("hello");
3059
});
3160

32-
it("returns JSON for short objects", () => {
33-
const obj = { key: "val" };
34-
expect(formatCellValue(obj)).toBe(JSON.stringify(obj));
61+
it("returns JSON for short objects (fast path)", () => {
62+
const obj = { key: "value" };
63+
expect(formatCellValue(obj)).toBe('{"key":"value"}');
3564
});
3665

37-
it("truncates JSON for objects exceeding the truncation length", () => {
38-
const longObj: Record<string, string> = {};
39-
for (let i = 0; i < 50; i++) {
40-
longObj[`key${i}`] = `value-${i}-padding`;
41-
}
42-
const json = JSON.stringify(longObj);
43-
// Precondition: the generated JSON is indeed longer than the limit.
44-
expect(json.length).toBeGreaterThan(EXTRACT_GRID_CELL_TRUNCATE_LENGTH);
66+
it("truncates long JSON at code-point boundary with ellipsis", () => {
67+
// Build an object whose JSON representation exceeds 100 chars
68+
const longValue = "x".repeat(120);
69+
const obj = { data: longValue };
70+
const result = formatCellValue(obj);
71+
// Should end with ellipsis and be at most 101 chars (100 code points + ellipsis)
72+
expect(result.endsWith("\u2026")).toBe(true);
73+
const codePoints = Array.from(result);
74+
// 100 code points from the slice + 1 ellipsis = 101
75+
expect(codePoints.length).toBe(101);
76+
});
77+
78+
it("handles emoji in long objects without splitting surrogate pairs", () => {
79+
// Each emoji is 1 code point but 2 UTF-16 code units (surrogate pair).
80+
// JSON wrapper `{"e":"..."}` adds 8 code points, so 95 emoji = 103 code points > 100.
81+
const emojis = "\u{1F600}".repeat(95);
82+
const obj = { e: emojis };
83+
const result = formatCellValue(obj);
84+
// Should truncate cleanly without U+FFFD replacement characters
85+
expect(result).not.toContain("\uFFFD");
86+
expect(result.endsWith("\u2026")).toBe(true);
87+
const codePoints = Array.from(result);
88+
expect(codePoints.length).toBe(101);
89+
});
4590

46-
const result = formatCellValue(longObj);
47-
expect(result.length).toBe(EXTRACT_GRID_CELL_TRUNCATE_LENGTH + 1); // +1 for ellipsis char
91+
it("truncates long raw strings at code-point boundary with ellipsis", () => {
92+
const longString = "a".repeat(150);
93+
const result = formatCellValue(longString);
4894
expect(result.endsWith("\u2026")).toBe(true);
95+
const codePoints = Array.from(result);
96+
expect(codePoints.length).toBe(101);
97+
});
98+
99+
it("handles emoji in long raw strings without splitting surrogate pairs", () => {
100+
const emojis = "\u{1F600}".repeat(105);
101+
const result = formatCellValue(emojis);
102+
expect(result).not.toContain("\uFFFD");
103+
expect(result.endsWith("\u2026")).toBe(true);
104+
const codePoints = Array.from(result);
105+
expect(codePoints.length).toBe(101);
106+
});
107+
108+
it("does not truncate object whose JSON has >100 UTF-16 units but <=100 code points", () => {
109+
// Build a string of 48 emoji: JSON = {"e":"<48 emoji>"} = 7 wrapper chars + 48 code points = 55 code points
110+
// But 7 + 48*2 = 103 UTF-16 units (exceeds 100 in .length but not in code points)
111+
const emojis = "\u{1F600}".repeat(48);
112+
const obj = { e: emojis };
113+
const json = JSON.stringify(obj);
114+
// Verify our premise: UTF-16 length > 100 but code point count <= 100
115+
expect(json.length).toBeGreaterThan(100);
116+
expect(Array.from(json).length).toBeLessThanOrEqual(100);
117+
const result = formatCellValue(obj);
118+
// Should return the full JSON without truncation
119+
expect(result).toBe(json);
120+
expect(result.endsWith("\u2026")).toBe(false);
49121
});
50122
});

frontend/src/hooks/useCamlComponentRenderer.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ export function useCamlComponentRenderer(
2929
): (md: string) => React.ReactNode {
3030
return useCallback(
3131
(md: string) => {
32-
const resolved = resolveComponentMarker(md, registry);
32+
// Use the marker string as the React key so that multiple
33+
// `[component:...]` blocks in a single article each get a stable,
34+
// unique identity for reconciliation. Without a key, React warns
35+
// "Each child in a list should have a unique 'key' prop" and falls
36+
// back to positional reconciliation.
37+
const resolved = resolveComponentMarker(md, registry, md);
3338
if (resolved) {
3439
return (
3540
<ErrorBoundary

0 commit comments

Comments
 (0)