Skip to content

Commit 4ac42df

Browse files
fix: support memo/forwardRef tag interpolation. (#96)
1 parent 2c086b1 commit 4ac42df

5 files changed

Lines changed: 124 additions & 8 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/jsx",
3-
"version": "1.13.1",
3+
"version": "1.13.2-rc.0",
44
"description": "Runtime JSX tagged template that renders DOM or React trees anywhere with or without a build step.",
55
"keywords": [
66
"jsx runtime",

src/react/react-jsx.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ import {
2424
type DOMAttributes,
2525
type EventHandler,
2626
type JSX as ReactJSX,
27+
type LazyExoticComponent,
28+
type MemoExoticComponent,
29+
type ForwardRefExoticComponent,
2730
type PropsWithChildren,
2831
type ReactElement,
2932
type ReactNode,
@@ -35,6 +38,13 @@ export type ReactJsxComponent<Props = Record<string, unknown>> = ComponentType<
3538
PropsWithChildren<Props>
3639
>
3740

41+
type ReactJsxExoticComponent =
42+
| MemoExoticComponent<ReactJsxComponent>
43+
| ForwardRefExoticComponent<Record<string, unknown>>
44+
| LazyExoticComponent<ReactJsxComponent>
45+
46+
type ReactJsxTagComponent = ReactJsxComponent | ReactJsxExoticComponent
47+
3848
export type ReactJsxRenderable = ReactNode
3949
export type ReactJsxChildren = ReactJsxRenderable | ReactJsxRenderable[]
4050
export type ReactJsxRef<T> = Ref<T>
@@ -44,7 +54,7 @@ export type ReactJsxIntrinsicElements = ReactJSX.IntrinsicElements
4454
export type ReactJsxIntrinsicElement<Tag extends keyof ReactJsxIntrinsicElements> =
4555
ReactJsxIntrinsicElements[Tag]
4656

47-
type ReactJsxContext = TemplateContext<ReactJsxComponent>
57+
type ReactJsxContext = TemplateContext<ReactJsxTagComponent>
4858

4959
const isIterable = (value: unknown): value is Iterable<unknown> => {
5060
if (!value || typeof value === 'string') {
@@ -172,13 +182,34 @@ const evaluateReactJsxChildren = (children: JSXChild[], ctx: ReactJsxContext) =>
172182
}
173183

174184
const createReactElement = (
175-
type: string | ReactJsxComponent,
185+
type: string | ReactJsxTagComponent,
176186
props: Record<string, unknown>,
177187
children: ReactNode[],
178188
) => {
179189
return createElement(type as never, props, ...children)
180190
}
181191

192+
const reactMemoSymbol = Symbol.for('react.memo')
193+
const reactForwardRefSymbol = Symbol.for('react.forward_ref')
194+
const reactLazySymbol = Symbol.for('react.lazy')
195+
196+
const isReactTagBindingValue = (value: unknown): value is ReactJsxTagComponent => {
197+
if (typeof value === 'function') {
198+
return true
199+
}
200+
201+
if (typeof value !== 'object' || value === null) {
202+
return false
203+
}
204+
205+
const candidate = value as { $$typeof?: unknown }
206+
return (
207+
candidate.$$typeof === reactMemoSymbol ||
208+
candidate.$$typeof === reactForwardRefSymbol ||
209+
candidate.$$typeof === reactLazySymbol
210+
)
211+
}
212+
182213
const evaluateReactJsxElement = (
183214
element: JSXElement,
184215
ctx: ReactJsxContext,
@@ -218,7 +249,9 @@ export const reactJsx = (
218249
templates: TemplateStringsArray,
219250
...values: unknown[]
220251
): ReactElement => {
221-
const build = buildTemplate<ReactJsxComponent>(templates, values)
252+
const build = buildTemplate<ReactJsxTagComponent>(templates, values, {
253+
isTagNameBindingValue: isReactTagBindingValue,
254+
})
222255
const result = parseSync('inline.jsx', build.source, parserOptions)
223256

224257
if (result.errors.length > 0) {

src/runtime/shared.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ export type TemplateBuildResult<TComponent extends TemplateComponent> = {
6868
diagnostics: TemplateDiagnostics
6969
}
7070

71+
export type TemplateBuildOptions<TComponent extends TemplateComponent> = {
72+
isTagNameBindingValue?: (value: unknown) => value is TComponent
73+
}
74+
7175
export type TemplateContext<TComponent extends TemplateComponent> = {
7276
source: string
7377
placeholders: Map<string, unknown>
@@ -272,6 +276,7 @@ export const ensureBinding = <TComponent extends TemplateComponent>(
272276
export const buildTemplate = <TComponent extends TemplateComponent>(
273277
strings: TemplateStringsArray,
274278
values: unknown[],
279+
options?: TemplateBuildOptions<TComponent>,
275280
): TemplateBuildResult<TComponent> => {
276281
const raw = strings.raw ?? strings
277282
const placeholders = new Map<string, unknown>()
@@ -281,6 +286,9 @@ export const buildTemplate = <TComponent extends TemplateComponent>(
281286
const templateId = invocationCounter++
282287
let placeholderIndex = 0
283288
const expressionRanges: TemplateExpressionRange[] = []
289+
const isTagNameBindingValue =
290+
options?.isTagNameBindingValue ??
291+
((value: unknown): value is TComponent => typeof value === 'function')
284292

285293
for (let idx = 0; idx < values.length; idx++) {
286294
const chunk = raw[idx] ?? ''
@@ -290,11 +298,15 @@ export const buildTemplate = <TComponent extends TemplateComponent>(
290298
const isTagNamePosition = OPEN_TAG_RE.test(chunk) || CLOSE_TAG_RE.test(chunk)
291299
let insertion: string
292300

293-
if (isTagNamePosition && typeof value === 'function') {
301+
if (isTagNamePosition && isTagNameBindingValue(value)) {
294302
const binding = ensureBinding(value as TComponent, bindings, bindingLookup)
295303
insertion = binding.name
296304
} else if (isTagNamePosition && typeof value === 'string') {
297305
insertion = value
306+
} else if (isTagNamePosition) {
307+
throw new Error(
308+
'Invalid tag interpolation value. Expected a component, class, or string tag name.',
309+
)
298310
} else {
299311
const placeholder = `${PLACEHOLDER_PREFIX}${templateId}_${placeholderIndex++}__`
300312
placeholders.set(placeholder, value)

test/react-jsx.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { beforeEach, describe, expect, it } from 'vitest'
22
import { createRoot } from 'react-dom/client'
3-
import { Children, act } from 'react'
3+
import { Children, act, forwardRef, memo } from 'react'
44
import type { ReactNode } from 'react'
55

66
import { reactJsx, type ReactJsxComponent } from '../src/react/index.js'
@@ -102,6 +102,77 @@ describe('reactJsx template tag', () => {
102102
})
103103
})
104104

105+
it('supports memo-wrapped components in tag interpolation', () => {
106+
const Button = memo(function Button({ label }: { label: string }) {
107+
return reactJsx`<button type="button">{${label}}</button>`
108+
})
109+
110+
const tree = reactJsx`
111+
<section>
112+
<${Button} label={${'Click Me'}} />
113+
</section>
114+
`
115+
116+
const container = document.createElement('div')
117+
document.body.append(container)
118+
const root = createRoot(container)
119+
120+
act(() => {
121+
root.render(tree)
122+
})
123+
124+
expect(container.querySelector('button')?.textContent).toBe('Click Me')
125+
expect(container.innerHTML).not.toContain('__KX_EXPR__')
126+
127+
act(() => {
128+
root.unmount()
129+
})
130+
})
131+
132+
it('supports forwardRef components in tag interpolation', () => {
133+
const Field = forwardRef<HTMLInputElement, { value: string }>(function Field(
134+
{ value },
135+
ref,
136+
) {
137+
return reactJsx`<input ref={${ref}} value={${value}} readOnly />`
138+
})
139+
140+
const tree = reactJsx`
141+
<section>
142+
<${Field} value={${'rc-check'}} />
143+
</section>
144+
`
145+
146+
const container = document.createElement('div')
147+
document.body.append(container)
148+
const root = createRoot(container)
149+
150+
act(() => {
151+
root.render(tree)
152+
})
153+
154+
const input = container.querySelector('input') as HTMLInputElement | null
155+
expect(input?.value).toBe('rc-check')
156+
expect(container.innerHTML).not.toContain('__KX_EXPR__')
157+
158+
act(() => {
159+
root.unmount()
160+
})
161+
})
162+
163+
it('does not treat React elements as tag interpolation components', () => {
164+
const readyElement = reactJsx`<button type="button">ready</button>`
165+
166+
expect(
167+
() =>
168+
reactJsx`
169+
<section>
170+
<${readyElement} />
171+
</section>
172+
`,
173+
).toThrow('Invalid tag interpolation value')
174+
})
175+
105176
it('throws when encountering unknown component names', () => {
106177
expect(() => reactJsx`<MissingComponent />`).toThrow(
107178
'Unknown component "MissingComponent"',

0 commit comments

Comments
 (0)