Skip to content

Commit c4ac279

Browse files
Copilotmorganney
andcommitted
Remove Children.map usage and implement alternative approach
Co-authored-by: morganney <848178+morganney@users.noreply.github.com>
1 parent 425d8f9 commit c4ac279

File tree

4 files changed

+215
-91
lines changed

4 files changed

+215
-91
lines changed

packages/tts-react/__tests__/hook.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,4 +321,60 @@ describe('useTts', () => {
321321
global.dispatchEvent(new Event('beforeunload'))
322322
expect(global.speechSynthesis.cancel).toHaveBeenCalled()
323323
})
324+
325+
it('accepts text prop as an alternative to children', async () => {
326+
const testText = 'This is direct text for TTS'
327+
const { result } = renderHook(
328+
({ text }) => useTts({ text }),
329+
{
330+
initialProps: {
331+
text: testText
332+
}
333+
}
334+
)
335+
336+
expect(result.current.spokenText).toBe(testText)
337+
expect(result.current.ttsChildren).toBe(testText)
338+
})
339+
340+
it('prefers text prop over children when both are provided', async () => {
341+
const testText = 'Direct text prop'
342+
const childrenText = 'Children text'
343+
const { result } = renderHook(
344+
({ text, children }) => useTts({ text, children }),
345+
{
346+
initialProps: {
347+
text: testText,
348+
children: <p>{childrenText}</p>
349+
}
350+
}
351+
)
352+
353+
expect(result.current.spokenText).toBe(testText)
354+
})
355+
356+
it('supports render prop for custom highlighting', async () => {
357+
const testText = 'Test text for highlighting'
358+
const mockRender = jest.fn((params) => params.children)
359+
360+
const { result } = renderHook(
361+
({ text, render, markTextAsSpoken }) => useTts({ text, render, markTextAsSpoken }),
362+
{
363+
initialProps: {
364+
text: testText,
365+
render: mockRender,
366+
markTextAsSpoken: true
367+
}
368+
}
369+
)
370+
371+
expect(result.current.spokenText).toBe(testText)
372+
expect(mockRender).toHaveBeenCalledWith({
373+
children: testText,
374+
boundary: expect.any(Object),
375+
markTextAsSpoken: true,
376+
markColor: undefined,
377+
markBackgroundColor: undefined
378+
})
379+
})
324380
})
Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,49 @@
1-
import { describe } from '@jest/globals'
1+
import { describe, test } from '@jest/globals'
2+
import { createElement } from 'react'
23

3-
import { isPunctuation } from '../src/utils.js'
4+
import { isPunctuation, extractTextFromChildren } from '../src/utils.js'
45

56
describe('utils', () => {
67
it('has isPunctuation', () => {
78
expect(isPunctuation('?')).toBe(true)
89
})
10+
11+
describe('extractTextFromChildren', () => {
12+
test('extracts text from string children', () => {
13+
expect(extractTextFromChildren('hello world')).toBe('hello world')
14+
})
15+
16+
test('extracts text from number children', () => {
17+
expect(extractTextFromChildren(42)).toBe('42')
18+
})
19+
20+
test('extracts text from array of children', () => {
21+
expect(extractTextFromChildren(['hello', ' ', 'world'])).toBe('hello world')
22+
})
23+
24+
test('extracts text from React elements', () => {
25+
const element = createElement('p', {}, 'Hello from element')
26+
expect(extractTextFromChildren(element)).toBe('Hello from element')
27+
})
28+
29+
test('extracts text from nested React elements', () => {
30+
const nested = createElement('span', {}, 'nested text')
31+
const element = createElement('p', {}, 'Hello ', nested, ' world')
32+
expect(extractTextFromChildren(element)).toBe('Hello nested text world')
33+
})
34+
35+
test('handles null and undefined children', () => {
36+
expect(extractTextFromChildren(null)).toBe('')
37+
expect(extractTextFromChildren(undefined)).toBe('')
38+
})
39+
40+
test('handles boolean children', () => {
41+
expect(extractTextFromChildren(true)).toBe('')
42+
expect(extractTextFromChildren(false)).toBe('')
43+
})
44+
45+
test('handles empty array', () => {
46+
expect(extractTextFromChildren([])).toBe('')
47+
})
48+
})
949
})

packages/tts-react/src/hook.tsx

Lines changed: 86 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,14 @@ import {
44
useReducer,
55
useCallback,
66
useEffect,
7-
Children,
8-
cloneElement,
97
createElement,
10-
isValidElement,
118
Fragment
129
} from 'react'
1310
import type { ReactNode } from 'react'
1411

1512
import { Controller, ControllerStub, Events } from './controller.js'
1613
import type { ControllerOptions, TTSBoundaryUpdate, TTSEvent } from './controller.js'
17-
import { isStringOrNumber, stripPunctuation, noop } from './utils.js'
14+
import { stripPunctuation, noop, extractTextFromChildren } from './utils.js'
1815
import { Highlighter } from './highlighter.js'
1916

2017
/**
@@ -78,9 +75,24 @@ interface MarkStyles {
7875
/** Background color of the currently marked word. */
7976
markBackgroundColor?: string
8077
}
78+
/**
79+
* Render prop function type for custom text highlighting
80+
*/
81+
type TTSRenderProp = (params: {
82+
children: ReactNode
83+
boundary: TTSBoundaryUpdate
84+
markTextAsSpoken: boolean
85+
markColor?: string
86+
markBackgroundColor?: string
87+
}) => ReactNode
88+
8189
interface TTSHookProps extends MarkStyles {
8290
/** The spoken text is extracted from here. */
83-
children: ReactNode
91+
children?: ReactNode
92+
/** Direct text to be spoken - alternative to children. */
93+
text?: string
94+
/** Render prop for custom highlighting - receives highlighting parameters */
95+
render?: TTSRenderProp
8496
/** The `SpeechSynthesisUtterance.lang` to use. */
8597
lang?: ControllerOptions['lang']
8698
/** The `SpeechSynthesisUtterance.voice` to use. */
@@ -175,82 +187,61 @@ interface TTSHookResponse {
175187
/** The original children with a possible <mark> included if using `markTextAsSpoken`. */
176188
ttsChildren: ReactNode
177189
}
178-
interface TextBuffer {
179-
text: string
180-
}
181-
interface ParseChildrenProps extends MarkStyles {
182-
children: ReactNode
183-
buffer: TextBuffer
184-
boundary: TTSBoundaryUpdate
185-
markTextAsSpoken: boolean
186-
}
190+
/**
191+
* Creates highlighted text using render prop pattern instead of Children.map
192+
*/
193+
const createHighlightedContent = (
194+
children: ReactNode,
195+
text: string,
196+
boundary: TTSBoundaryUpdate,
197+
markTextAsSpoken: boolean,
198+
markColor?: string,
199+
markBackgroundColor?: string,
200+
renderProp?: TTSRenderProp
201+
): ReactNode => {
202+
// If a render prop is provided, use it
203+
if (renderProp) {
204+
return renderProp({
205+
children,
206+
boundary,
207+
markTextAsSpoken,
208+
markColor,
209+
markBackgroundColor
210+
})
211+
}
187212

188-
const isObjectReactNode = (value: unknown): value is Record<string, ReactNode> =>
189-
typeof value === 'object' && value !== null
190-
const parseChildrenRecursively = ({
191-
children,
192-
buffer,
193-
boundary,
194-
markColor,
195-
markBackgroundColor,
196-
markTextAsSpoken
197-
}: ParseChildrenProps): ReactNode => {
198-
return Children.map(children, (child) => {
199-
let currentChild = child
200-
201-
if (isValidElement(child)) {
202-
const childProps = isObjectReactNode(child.props) ? child.props : {}
203-
204-
currentChild = cloneElement(child, {
205-
...childProps,
206-
// @ts-expect-error - `children` is not a valid prop for ReactElement
207-
children: parseChildrenRecursively({
208-
buffer,
209-
boundary,
210-
markColor,
211-
markBackgroundColor,
212-
markTextAsSpoken,
213-
children: childProps.children
214-
})
215-
})
216-
}
213+
// If not marking text as spoken or no boundary word, return original children
214+
if (!markTextAsSpoken || !boundary.word || !children) {
215+
return children
216+
}
217217

218-
if (isStringOrNumber(child)) {
219-
const text = (child as string | number).toString()
220-
const { word, startChar, endChar } = boundary
221-
const bufferTextLength = buffer.text.length
222-
223-
buffer.text += `${text} `
224-
225-
if (markTextAsSpoken && word) {
226-
const start = startChar - bufferTextLength
227-
const end = endChar - bufferTextLength
228-
const prev = text.substring(0, start)
229-
const found = text.substring(start, end)
230-
const after = text.substring(end, text.length)
231-
232-
if (found) {
233-
const Highlight = createElement(Highlighter, {
234-
text: found,
235-
mark: stripPunctuation(found),
236-
color: markColor,
237-
backgroundColor: markBackgroundColor
238-
})
239-
const Highlighted = createElement(
240-
Fragment,
241-
{ key: `tts-${start}-${end}` },
242-
prev,
243-
Highlight,
244-
after
245-
)
246-
247-
return Highlighted
248-
}
249-
}
218+
// For simple text content, apply highlighting directly
219+
if (typeof children === 'string') {
220+
const { word, startChar, endChar } = boundary
221+
const prev = text.substring(0, startChar)
222+
const found = text.substring(startChar, endChar)
223+
const after = text.substring(endChar)
224+
225+
if (found && stripPunctuation(found) === stripPunctuation(word)) {
226+
const Highlight = createElement(Highlighter, {
227+
text: found,
228+
mark: stripPunctuation(found),
229+
color: markColor,
230+
backgroundColor: markBackgroundColor
231+
})
232+
233+
return createElement(
234+
Fragment,
235+
{ key: `tts-${startChar}-${endChar}` },
236+
prev,
237+
Highlight,
238+
after
239+
)
250240
}
241+
}
251242

252-
return currentChild
253-
})
243+
// For complex content, return as-is (highlighting would need custom render prop)
244+
return children
254245
}
255246
const defaultBoundary = { word: '', startChar: 0, endChar: 0 }
256247
const reducer = (state: TTSHookState, action: Action): TTSHookState => {
@@ -305,6 +296,8 @@ const useTts = ({
305296
volume,
306297
voice,
307298
children,
299+
text,
300+
render,
308301
markColor,
309302
markBackgroundColor,
310303
onStart,
@@ -334,20 +327,24 @@ const useTts = ({
334327
isReady: isSynthSupported && typeof fetchAudioData === 'undefined'
335328
})
336329
const [ttsChildren, spokenText] = useMemo(() => {
337-
const buffer: TextBuffer = { text: '' }
338-
const parsed = parseChildrenRecursively({
339-
children,
340-
buffer,
330+
// Use text prop if provided, otherwise extract from children
331+
const extractedText = text || (children ? extractTextFromChildren(children) : '')
332+
333+
// Create highlighted content using the new approach
334+
const highlightedContent = createHighlightedContent(
335+
children || extractedText,
336+
extractedText,
337+
state.boundary,
338+
markTextAsSpoken,
341339
markColor,
342340
markBackgroundColor,
343-
markTextAsSpoken,
344-
boundary: state.boundary
345-
})
341+
render
342+
)
346343

347-
spokenTextRef.current = buffer.text.trim()
344+
spokenTextRef.current = extractedText.trim()
348345

349-
return [parsed, spokenTextRef.current]
350-
}, [children, state.boundary, markColor, markBackgroundColor, markTextAsSpoken])
346+
return [highlightedContent, spokenTextRef.current]
347+
}, [children, text, state.boundary, markColor, markBackgroundColor, markTextAsSpoken, render])
351348
const controller = useMemo(
352349
() =>
353350
isSynthSupported
@@ -674,5 +671,6 @@ export type {
674671
TTSEventHandler,
675672
TTSErrorHandler,
676673
TTSBoundaryHandler,
677-
TTSAudioChangeHandler
674+
TTSAudioChangeHandler,
675+
TTSRenderProp
678676
}

packages/tts-react/src/utils.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ReactNode } from 'react'
2+
import { isValidElement } from 'react'
23

34
const punctuationRgx = /[^\P{P}'/-]+/gu
45
const isStringOrNumber = (value: ReactNode): boolean => {
@@ -14,4 +15,33 @@ const isPunctuation = (text: string): boolean => {
1415
}
1516
const noop = (): void => {}
1617

17-
export { isStringOrNumber, stripPunctuation, isPunctuation, noop, punctuationRgx }
18+
/**
19+
* Extracts text content from React children without using Children.map
20+
* This is an alternative to the legacy Children.map API
21+
*/
22+
const extractTextFromChildren = (children: ReactNode): string => {
23+
if (!children) {
24+
return ''
25+
}
26+
27+
// Handle string and number primitives
28+
if (typeof children === 'string' || typeof children === 'number') {
29+
return children.toString()
30+
}
31+
32+
// Handle arrays of children
33+
if (Array.isArray(children)) {
34+
return children.map((child: ReactNode) => extractTextFromChildren(child)).join(' ')
35+
}
36+
37+
// Handle React elements
38+
if (isValidElement(children) && children.props && 'children' in children.props) {
39+
// For React elements, recursively extract text from their children
40+
return extractTextFromChildren(children.props.children as ReactNode)
41+
}
42+
43+
// Handle other types (null, undefined, boolean, etc.)
44+
return ''
45+
}
46+
47+
export { isStringOrNumber, stripPunctuation, isPunctuation, noop, punctuationRgx, extractTextFromChildren }

0 commit comments

Comments
 (0)