Skip to content

Commit e35c0ff

Browse files
Memoize ref, handleTokenClick, and tokenArrays; introduce TokenChip type guard to ensure that input always has a handler; update docs
1 parent c390d64 commit e35c0ff

8 files changed

Lines changed: 122 additions & 65 deletions

File tree

src/__tests__/components/PhraseBox.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,32 +33,32 @@ jest.mock('../../components/TokenChip', () => ({
3333
}));
3434

3535
/** Pre-built test token */
36-
const TEST_TOKEN: Token = {
36+
const TEST_TOKEN = {
3737
id: 'token-1',
3838
surfaceText: 'Hello',
3939
writingSystem: 'en',
4040
type: 'word',
4141
charStart: 0,
4242
charEnd: 5,
43-
};
43+
} satisfies Token;
4444

4545
/** Second test token */
46-
const TEST_TOKEN_2: Token = {
46+
const TEST_TOKEN_2 = {
4747
id: 'token-2',
4848
surfaceText: 'World',
4949
writingSystem: 'en',
5050
type: 'word',
5151
charStart: 6,
5252
charEnd: 11,
53-
};
53+
} satisfies Token;
5454

5555
/** Shared props shape used by both helper functions. */
5656
type PhraseBoxTestProps = {
5757
glosses: Record<string, string>;
5858
isFocused: boolean;
5959
onClick?: (index?: number) => void;
6060
onGlossChange: (tokenId: string, value: string) => void;
61-
tokens: Token[];
61+
tokens: (Token & { type: 'word' })[];
6262
};
6363

6464
/**

src/__tests__/components/SegmentView.test.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,24 @@ jest.mock('../../components/PhraseBox', () => ({
1111
__esModule: true,
1212
default: ({
1313
glosses,
14+
index,
1415
isFocused = false,
1516
onClick,
1617
onGlossChange,
1718
tokens,
1819
}: {
1920
glosses: Record<string, string>;
21+
index?: number;
2022
isFocused: boolean;
21-
onClick?: () => void;
23+
onClick?: (index?: number) => void;
2224
onGlossChange: (tokenId: string, value: string) => void;
2325
tokens: Token[];
2426
}) => (
2527
<span data-focus-state={isFocused ? 'focused' : 'default'}>
2628
{tokens.map((t) => (
2729
<span key={t.id}>
2830
{onClick ? (
29-
<button onClick={onClick} type="button">
31+
<button onClick={() => onClick(index)} type="button">
3032
{t.surfaceText}
3133
</button>
3234
) : (

src/__tests__/components/TokenChip.test.tsx

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,42 @@ import userEvent from '@testing-library/user-event';
77
import type { Token } from 'interlinearizer';
88
import { TokenChip } from '../../components/TokenChip';
99

10-
const WORD_TOKEN: Token = {
10+
const WORD_TOKEN = {
1111
id: 'GEN 1:1:0',
1212
surfaceText: 'hello',
1313
writingSystem: 'en',
1414
type: 'word',
1515
charStart: 0,
1616
charEnd: 5,
17-
};
17+
} satisfies Token;
1818

19-
const PUNCT_TOKEN: Token = {
19+
const PUNCT_TOKEN = {
2020
id: 'GEN 1:1:5',
2121
surfaceText: '.',
2222
writingSystem: 'en',
2323
type: 'punctuation',
2424
charStart: 5,
2525
charEnd: 6,
26-
};
26+
} satisfies Token;
27+
28+
/**
29+
* Minimal required props for a word-token `TokenChip`. Spread into render calls so tests only need
30+
* to override what they actually care about.
31+
*
32+
* @returns An object with all required word-token props set to no-op stubs.
33+
*/
34+
function requiredWordProps() {
35+
return {
36+
token: WORD_TOKEN,
37+
gloss: '',
38+
onFocus: jest.fn(),
39+
onGlossChange: jest.fn(),
40+
} as const;
41+
}
2742

2843
describe('TokenChip', () => {
2944
it('renders the surface text for a word token', () => {
30-
render(<TokenChip token={WORD_TOKEN} />);
45+
render(<TokenChip {...requiredWordProps()} />);
3146
expect(screen.getByText('hello')).toBeInTheDocument();
3247
});
3348

@@ -37,7 +52,7 @@ describe('TokenChip', () => {
3752
});
3853

3954
it('applies a border class to word tokens', () => {
40-
render(<TokenChip token={WORD_TOKEN} />);
55+
render(<TokenChip {...requiredWordProps()} />);
4156
// The outer container holds the border; the inner span is just the surface text
4257
const outer = screen.getByText('hello').closest('span')?.parentElement;
4358
expect(outer?.className).toContain('tw:border');
@@ -50,14 +65,14 @@ describe('TokenChip', () => {
5065
});
5166

5267
it('renders word and punctuation tokens as inline spans', () => {
53-
const { container: wc } = render(<TokenChip token={WORD_TOKEN} />);
68+
const { container: wc } = render(<TokenChip {...requiredWordProps()} />);
5469
const { container: pc } = render(<TokenChip token={PUNCT_TOKEN} />);
5570
expect(wc.querySelector('span')).toBeInTheDocument();
5671
expect(pc.querySelector('span')).toBeInTheDocument();
5772
});
5873

5974
it('renders a gloss input for word tokens', () => {
60-
render(<TokenChip token={WORD_TOKEN} />);
75+
render(<TokenChip {...requiredWordProps()} />);
6176
expect(screen.getByRole('textbox', { name: 'Gloss for hello' })).toBeInTheDocument();
6277
});
6378

@@ -67,41 +82,28 @@ describe('TokenChip', () => {
6782
});
6883

6984
it('shows the current gloss value in the input', () => {
70-
render(<TokenChip token={WORD_TOKEN} gloss="in" />);
85+
render(<TokenChip {...requiredWordProps()} gloss="in" />);
7186
expect(screen.getByRole('textbox', { name: 'Gloss for hello' })).toHaveValue('in');
7287
});
7388

74-
it('shows an empty input when no gloss is provided', () => {
75-
render(<TokenChip token={WORD_TOKEN} />);
89+
it('shows an empty string in the input when gloss is empty', () => {
90+
render(<TokenChip {...requiredWordProps()} gloss="" />);
7691
expect(screen.getByRole('textbox', { name: 'Gloss for hello' })).toHaveValue('');
7792
});
7893

7994
it('calls onGlossChange for each keystroke', async () => {
8095
const handleChange = jest.fn();
81-
render(<TokenChip token={WORD_TOKEN} onGlossChange={handleChange} />);
96+
render(<TokenChip {...requiredWordProps()} onGlossChange={handleChange} />);
8297
await userEvent.type(screen.getByRole('textbox', { name: 'Gloss for hello' }), 'in');
8398
expect(handleChange).toHaveBeenCalledTimes(2);
8499
expect(handleChange).toHaveBeenNthCalledWith(1, 'i');
85100
expect(handleChange).toHaveBeenNthCalledWith(2, 'n');
86101
});
87102

88-
it('does not throw when onGlossChange is omitted and the user types', async () => {
89-
render(<TokenChip token={WORD_TOKEN} />);
90-
await userEvent.type(screen.getByRole('textbox', { name: 'Gloss for hello' }), 'in');
91-
// No assertion needed — test passes if no error is thrown
92-
});
93-
94103
it('calls onFocus when the input is focused', async () => {
95104
const handleFocus = jest.fn();
96-
render(<TokenChip token={WORD_TOKEN} onFocus={handleFocus} />);
105+
render(<TokenChip {...requiredWordProps()} onFocus={handleFocus} />);
97106
await userEvent.click(screen.getByRole('textbox', { name: 'Gloss for hello' }));
98107
expect(handleFocus).toHaveBeenCalledTimes(1);
99108
});
100-
101-
it('does not throw when onFocus is omitted', async () => {
102-
render(<TokenChip token={WORD_TOKEN} />);
103-
await userEvent.click(screen.getByRole('textbox', { name: 'Gloss for hello' }));
104-
await userEvent.tab();
105-
// No assertion needed — test passes if no error is thrown
106-
});
107109
});

src/components/ContinuousView.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,22 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
33
import MemoizedPhraseBox from './PhraseBox';
44
import MemoizedTokenChip from './TokenChip';
55

6-
/** CSS easing for the strip opacity fade-in/out animation. */
6+
/**
7+
* Narrows a `Token` to a word token.
8+
*
9+
* @param token - The token to test.
10+
* @returns `true` when `token.type === 'word'`.
11+
*/
12+
function isWordToken(token: Token): token is Token & { type: 'word' } {
13+
return token.type === 'word';
14+
}
15+
16+
/**
17+
* CSS easing for the strip opacity fade-in/out animation. Uses a sine-like curve for a natural feel
18+
* at both ends of the transition.
19+
*/
720
const STRIP_FADE_EASING = 'cubic-bezier(0.65, 0, 0.35, 1)';
21+
822
/**
923
* Duration of the strip fade animation in milliseconds. Must match the `setTimeout` in the
1024
* pending-jump effect.
@@ -435,7 +449,7 @@ export default function ContinuousView({
435449
>
436450
{allTokens.slice(windowStartTokenIndex, windowEndTokenIndex).map((token, i) => {
437451
const tokenIndex = windowStartTokenIndex + i;
438-
if (token.type !== 'word') return <MemoizedTokenChip key={token.id} token={token} />;
452+
if (!isWordToken(token)) return <MemoizedTokenChip key={token.id} token={token} />;
439453

440454
const phraseIndex = phraseIndexByTokenIndex.get(tokenIndex);
441455
const isFocusedPhrase = phraseIndex !== undefined && phraseIndex === focusPhraseIndex;

src/components/InterlinearizerLoader.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const STRING_KEYS = ['%interlinearizer_continuousScrollToggle%'] as const;
1919
* @param props.projectId - PAPI project ID passed from the host
2020
* @param props.useWebViewScrollGroupScrRef - Hook that exposes the shared scroll-group scripture
2121
* reference and its setter
22+
* @returns The toolbar and either an error/loading state or the fully rendered
23+
* {@link Interlinearizer}
2224
*/
2325
export default function InterlinearizerLoader({
2426
projectId,

src/components/PhraseBox.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export function PhraseBox({
3030
isFocused: boolean;
3131
onClick?: (index?: number) => void;
3232
onGlossChange: (tokenId: string, value: string) => void;
33-
tokens: Token[];
33+
tokens: (Token & { type: 'word' })[];
3434
}>) {
3535
const baseClass = isFocused
3636
? 'tw:inline-flex tw:items-center tw:rounded tw:border-2 tw:border-white tw:bg-muted/30 tw:px-1 tw:py-0.5'
@@ -75,5 +75,6 @@ export function PhraseBox({
7575
);
7676
}
7777

78+
/** Memoized version of {@link PhraseBox}; use this for all render-stable phrase lists. */
7879
const MemoizedPhraseBox = memo(PhraseBox);
7980
export default MemoizedPhraseBox;

src/components/SegmentView.tsx

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
1-
import type { ScriptureRef, Segment } from 'interlinearizer';
2-
import { memo } from 'react';
1+
import type { ScriptureRef, Segment, Token } from 'interlinearizer';
2+
import { memo, useCallback, useMemo } from 'react';
33
import MemoizedPhraseBox from './PhraseBox';
44
import MemoizedTokenChip from './TokenChip';
55

6+
/**
7+
* Narrows a `Token` to a word token.
8+
*
9+
* @param token - The token to test.
10+
* @returns `true` when `token.type === 'word'`.
11+
*/
12+
function isWordToken(token: Token): token is Token & { type: 'word' } {
13+
return token.type === 'word';
14+
}
15+
616
/**
717
* The two display modes for {@link SegmentView}.
818
*
@@ -50,14 +60,29 @@ export function SegmentView({
5060
segment: Segment;
5161
}>) {
5262
const { book, chapter, verse } = segment.startRef;
53-
const ref: ScriptureRef = { book, chapter, verse };
63+
const ref: ScriptureRef = useMemo(() => ({ book, chapter, verse }), [book, chapter, verse]);
64+
5465
/**
55-
* Forwards a token-chip click to the parent as a scripture reference + token id.
66+
* Forwards a token-chip click (identified by its index in `segment.tokens`) to the parent as a
67+
* scripture reference + token id. Stable across renders so `MemoizedPhraseBox` can memoize.
5668
*
57-
* @param tokenId - The id of the clicked token.
58-
* @throws Propagates any error thrown by `onSelect`.
69+
* @param index - Index of the clicked token within `segment.tokens`.
5970
*/
60-
const handleTokenClick = (tokenId: string) => onSelect(ref, tokenId);
71+
const handleTokenClick = useCallback(
72+
(index?: number) => {
73+
if (index !== undefined) onSelect(ref, segment.tokens[index].id);
74+
},
75+
[onSelect, ref, segment.tokens],
76+
);
77+
78+
/**
79+
* Stable single-token arrays for word tokens keyed by position, so `MemoizedPhraseBox` receives
80+
* the same reference across renders.
81+
*/
82+
const tokenArrays = useMemo(
83+
() => segment.tokens.map((token) => (isWordToken(token) ? [token] : [])),
84+
[segment.tokens],
85+
);
6186

6287
const sharedClassName = isActive
6388
? 'tw:w-full tw:rounded tw:border tw:border-border tw:bg-muted/50 tw:p-2'
@@ -92,15 +117,16 @@ export function SegmentView({
92117
>
93118
{verseLabel}
94119
<span className="tw:flex tw:flex-wrap tw:gap-1">
95-
{segment.tokens.map((token) =>
120+
{segment.tokens.map((token, index) =>
96121
token.type === 'word' ? (
97122
<MemoizedPhraseBox
98123
key={token.id}
99124
glosses={glosses}
125+
index={index}
100126
isFocused={focusedTokenId === token.id}
101-
onClick={() => handleTokenClick(token.id)}
127+
onClick={handleTokenClick}
102128
onGlossChange={onGlossChange}
103-
tokens={[token]}
129+
tokens={tokenArrays[index]}
104130
/>
105131
) : (
106132
<MemoizedTokenChip key={token.id} token={token} />
@@ -111,5 +137,6 @@ export function SegmentView({
111137
);
112138
}
113139

140+
/** Memoized version of {@link SegmentView}; use this for all render-stable segment lists. */
114141
const MemoizedSegmentView = memo(SegmentView);
115142
export default MemoizedSegmentView;

src/components/TokenChip.tsx

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,38 @@
11
import type { Token } from 'interlinearizer';
22
import { memo } from 'react';
33

4+
/** Props for a word token chip; requires gloss editing callbacks. */
5+
type WordProps = Readonly<{
6+
token: Token & { type: 'word' };
7+
gloss: string;
8+
onFocus: () => void;
9+
onGlossChange: (value: string) => void;
10+
}>;
11+
12+
/** Props for a punctuation token chip; editing props are excluded via `never`. */
13+
type PunctProps = Readonly<{
14+
token: Token;
15+
gloss?: never;
16+
onFocus?: never;
17+
onGlossChange?: never;
18+
}>;
19+
420
/**
521
* Renders a single token as an inline chip. Word tokens get a bordered box with an editable gloss
6-
* input below; non-word tokens (e.g. punctuation) are rendered as muted inline text with no gloss.
22+
* input below; punctuation tokens are rendered as muted inline text with no gloss.
23+
*
24+
* Props are a discriminated union on `token.type`: word tokens require `onGlossChange` and accept
25+
* `gloss` and `onFocus`; punctuation tokens accept none of these.
726
*
827
* @param props - Component props
9-
* @param props.gloss - Current gloss text for this token (English). Absent when no gloss has been
10-
* set.
11-
* @param props.onFocus - Called when the gloss input receives focus; used by the parent to track
12-
* which token is active.
13-
* @param props.onGlossChange - Called with the new gloss value when the user edits the input.
14-
* @param props.token - The token to render
28+
* @param props.token - The token to render; its `type` field discriminates the union.
29+
* @param props.gloss - (word only) Current gloss text. Absent when no gloss has been set.
30+
* @param props.onFocus - (word only) Called when the gloss input receives focus.
31+
* @param props.onGlossChange - (word only) Called with the new gloss value when the user edits the
32+
* input.
1533
* @returns A styled inline block
1634
*/
17-
export function TokenChip({
18-
gloss,
19-
onFocus,
20-
onGlossChange,
21-
token,
22-
}: Readonly<{
23-
gloss?: string;
24-
onFocus?: () => void;
25-
onGlossChange?: (value: string) => void;
26-
token: Token;
27-
}>) {
35+
export function TokenChip({ gloss, onFocus, onGlossChange, token }: WordProps | PunctProps) {
2836
return token.type === 'word' ? (
2937
<span className="tw:inline-flex tw:shrink-0 tw:flex-col tw:items-center tw:rounded tw:border tw:border-border tw:bg-muted tw:px-1.5 tw:py-0.5">
3038
<span className="tw:whitespace-nowrap tw:font-mono tw:text-sm tw:text-foreground">
@@ -34,7 +42,7 @@ export function TokenChip({
3442
aria-label={`Gloss for ${token.surfaceText}`}
3543
className="tw:mt-0.5 tw:rounded tw:border tw:border-border tw:bg-background tw:px-1 tw:text-center tw:text-sm tw:text-foreground tw:outline-none tw:focus:border-ring tw:focus:ring-1 tw:focus:ring-ring"
3644
style={{ fieldSizing: 'content', minWidth: '5ch' }}
37-
value={gloss ?? ''}
45+
value={gloss}
3846
onChange={(e) => onGlossChange?.(e.target.value)}
3947
onFocus={onFocus}
4048
type="text"
@@ -47,5 +55,6 @@ export function TokenChip({
4755
);
4856
}
4957

58+
/** Memoized version of {@link TokenChip}; use this for all render-stable token lists. */
5059
const MemoizedTokenChip = memo(TokenChip);
5160
export default MemoizedTokenChip;

0 commit comments

Comments
 (0)