Skip to content

Commit 50f102a

Browse files
authored
fix: expressions scopes, member expressions, optional chaining (#276)
1 parent 67bb9b9 commit 50f102a

3 files changed

Lines changed: 126 additions & 53 deletions

File tree

source/components/JsxParser.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,42 @@ describe('JsxParser Component', () => {
958958
expect(node.childNodes[0].textContent).toEqual(bindings.array[bindings.index].of)
959959
expect(instance.ParsedChildren[0].props.foo).toEqual(bindings.array[bindings.index].of)
960960
})
961+
it('can evaluate a[b]', () => {
962+
const { node } = render(
963+
<JsxParser
964+
bindings={{ items: { 0: 'hello', 1: 'world' }, arr: [0, 1] }}
965+
jsx="{items[arr[0]]}"
966+
/>,
967+
)
968+
expect(node.innerHTML).toMatch('hello')
969+
})
970+
it('handles optional chaining', () => {
971+
const { node } = render(
972+
<JsxParser
973+
bindings={{ foo: { bar: 'baz' }, baz: undefined }}
974+
jsx="{foo?.bar} {baz?.bar}"
975+
/>,
976+
)
977+
expect(node.innerHTML).toMatch('baz')
978+
})
979+
it('optional short-cut', () => {
980+
const { node } = render(
981+
<JsxParser
982+
bindings={{ foo: { bar: { baz: 'baz' } }, foo2: undefined }}
983+
jsx="{foo?.bar.baz} {foo2?.bar.baz}"
984+
/>,
985+
)
986+
expect(node.innerHTML).toMatch('baz')
987+
})
988+
it('optional function call', () => {
989+
const { node } = render(
990+
<JsxParser
991+
bindings={{ foo: { bar: () => 'baz' }, foo2: undefined }}
992+
jsx="{foo?.bar()} {foo2?.bar()}"
993+
/>,
994+
)
995+
expect(node.innerHTML).toMatch('baz')
996+
})
961997
/* eslint-enable dot-notation,no-useless-concat */
962998
})
963999
})
@@ -1264,5 +1300,15 @@ describe('JsxParser Component', () => {
12641300
)
12651301
expect(node.outerHTML).toEqual('<p>from-container</p>')
12661302
})
1303+
1304+
it('supports math with scope', () => {
1305+
const { node } = render(<JsxParser jsx="{[1, 2, 3].map(num => num * 2)}" />)
1306+
expect(node.innerHTML).toEqual('246')
1307+
})
1308+
1309+
it('supports conditional with scope', () => {
1310+
const { node } = render(<JsxParser jsx="{[1, 2, 3].map(num => num == 1 || num == 3 ? num : -1)}" />)
1311+
expect(node.innerHTML).toEqual('1-13')
1312+
})
12671313
})
12681314
})

source/components/JsxParser.tsx

Lines changed: 69 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ export type TProps = {
2929
}
3030
type Scope = Record<string, any>
3131

32+
class NullishShortCircuit extends Error {
33+
constructor(message = 'Nullish value encountered') {
34+
super(message)
35+
this.name = 'NullishShortCircuit'
36+
}
37+
}
38+
3239
/* eslint-disable consistent-return */
3340
export default class JsxParser extends React.Component<TProps> {
3441
static displayName = 'JsxParser'
@@ -94,40 +101,45 @@ export default class JsxParser extends React.Component<TProps> {
94101
case 'ArrayExpression':
95102
return expression.elements.map(ele => this.#parseExpression(ele, scope)) as ParsedTree
96103
case 'BinaryExpression':
104+
const binaryLeft = this.#parseExpression(expression.left, scope)
105+
const binaryRight = this.#parseExpression(expression.right, scope)
97106
/* eslint-disable eqeqeq,max-len */
98107
switch (expression.operator) {
99-
case '-': return this.#parseExpression(expression.left) - this.#parseExpression(expression.right)
100-
case '!=': return this.#parseExpression(expression.left) != this.#parseExpression(expression.right)
101-
case '!==': return this.#parseExpression(expression.left) !== this.#parseExpression(expression.right)
102-
case '*': return this.#parseExpression(expression.left) * this.#parseExpression(expression.right)
103-
case '**': return this.#parseExpression(expression.left) ** this.#parseExpression(expression.right)
104-
case '/': return this.#parseExpression(expression.left) / this.#parseExpression(expression.right)
105-
case '%': return this.#parseExpression(expression.left) % this.#parseExpression(expression.right)
106-
case '+': return this.#parseExpression(expression.left) + this.#parseExpression(expression.right)
107-
case '<': return this.#parseExpression(expression.left) < this.#parseExpression(expression.right)
108-
case '<=': return this.#parseExpression(expression.left) <= this.#parseExpression(expression.right)
109-
case '==': return this.#parseExpression(expression.left) == this.#parseExpression(expression.right)
110-
case '===': return this.#parseExpression(expression.left) === this.#parseExpression(expression.right)
111-
case '>': return this.#parseExpression(expression.left) > this.#parseExpression(expression.right)
112-
case '>=': return this.#parseExpression(expression.left) >= this.#parseExpression(expression.right)
108+
case '-': return binaryLeft - binaryRight
109+
case '!=': return binaryLeft != binaryRight
110+
case '!==': return binaryLeft !== binaryRight
111+
case '*': return binaryLeft * binaryRight
112+
case '**': return binaryLeft ** binaryRight
113+
case '/': return binaryLeft / binaryRight
114+
case '%': return binaryLeft % binaryRight
115+
case '+': return binaryLeft + binaryRight
116+
case '<': return binaryLeft < binaryRight
117+
case '<=': return binaryLeft <= binaryRight
118+
case '==': return binaryLeft == binaryRight
119+
case '===': return binaryLeft === binaryRight
120+
case '>': return binaryLeft > binaryRight
121+
case '>=': return binaryLeft >= binaryRight
113122
/* eslint-enable eqeqeq,max-len */
114123
}
115124
return undefined
116125
case 'CallExpression':
117-
const parsedCallee = this.#parseExpression(expression.callee)
126+
const parsedCallee = this.#parseExpression(expression.callee, scope)
118127
if (parsedCallee === undefined) {
128+
if (expression.optional) {
129+
throw new NullishShortCircuit()
130+
}
119131
this.props.onError!(new Error(`The expression '${expression.callee}' could not be resolved, resulting in an undefined return value.`))
120132
return undefined
121133
}
122134
return parsedCallee(...expression.arguments.map(
123135
arg => this.#parseExpression(arg, expression.callee),
124136
))
125137
case 'ConditionalExpression':
126-
return this.#parseExpression(expression.test)
127-
? this.#parseExpression(expression.consequent)
128-
: this.#parseExpression(expression.alternate)
138+
return this.#parseExpression(expression.test, scope)
139+
? this.#parseExpression(expression.consequent, scope)
140+
: this.#parseExpression(expression.alternate, scope)
129141
case 'ExpressionStatement':
130-
return this.#parseExpression(expression.expression)
142+
return this.#parseExpression(expression.expression, scope)
131143
case 'Identifier':
132144
if (scope && expression.name in scope) {
133145
return scope[expression.name]
@@ -137,18 +149,20 @@ export default class JsxParser extends React.Component<TProps> {
137149
case 'Literal':
138150
return expression.value
139151
case 'LogicalExpression':
140-
const left = this.#parseExpression(expression.left)
152+
const left = this.#parseExpression(expression.left, scope)
141153
if (expression.operator === '||' && left) return left
142154
if ((expression.operator === '&&' && left) || (expression.operator === '||' && !left)) {
143-
return this.#parseExpression(expression.right)
155+
return this.#parseExpression(expression.right, scope)
144156
}
145157
return false
146158
case 'MemberExpression':
147159
return this.#parseMemberExpression(expression, scope)
160+
case 'ChainExpression':
161+
return this.#parseChainExpression(expression, scope)
148162
case 'ObjectExpression':
149163
const object: Record<string, any> = {}
150164
expression.properties.forEach(prop => {
151-
object[prop.key.name! || prop.key.value!] = this.#parseExpression(prop.value)
165+
object[prop.key.name! || prop.key.value!] = this.#parseExpression(prop.value, scope)
152166
})
153167
return object
154168
case 'TemplateElement':
@@ -159,7 +173,7 @@ export default class JsxParser extends React.Component<TProps> {
159173
if (a.start < b.start) return -1
160174
return 1
161175
})
162-
.map(item => this.#parseExpression(item))
176+
.map(item => this.#parseExpression(item, scope))
163177
.join('')
164178
case 'UnaryExpression':
165179
switch (expression.operator) {
@@ -179,41 +193,48 @@ export default class JsxParser extends React.Component<TProps> {
179193
})
180194
return this.#parseExpression(expression.body, functionScope)
181195
}
196+
default:
197+
this.props.onError!(new Error(`The expression type '${expression.type}' is not supported.`))
198+
return undefined
199+
}
200+
}
201+
202+
#parseChainExpression = (expression: AcornJSX.ChainExpression, scope?: Scope): any => {
203+
try {
204+
return this.#parseExpression(expression.expression, scope)
205+
} catch (error) {
206+
if (error instanceof NullishShortCircuit) return undefined
207+
throw error
182208
}
183209
}
184210

185211
#parseMemberExpression = (expression: AcornJSX.MemberExpression, scope?: Scope): any => {
186-
// eslint-disable-next-line prefer-destructuring
187-
let { object } = expression
188-
const path = [expression.property?.name ?? JSON.parse(expression.property?.raw ?? '""')]
212+
const object = this.#parseExpression(expression.object, scope)
189213

190-
if (expression.object.type !== 'Literal') {
191-
while (object && ['MemberExpression', 'Literal'].includes(object?.type)) {
192-
const { property } = (object as AcornJSX.MemberExpression)
193-
if ((object as AcornJSX.MemberExpression).computed) {
194-
path.unshift(this.#parseExpression(property!, scope))
195-
} else {
196-
path.unshift(property?.name ?? JSON.parse(property?.raw ?? '""'))
197-
}
214+
let property
198215

199-
object = (object as AcornJSX.MemberExpression).object
200-
}
216+
if (expression.computed) {
217+
property = this.#parseExpression(expression.property, scope)
218+
} else if (expression.property.type === 'Identifier') {
219+
property = expression.property.name
220+
} else {
221+
this.props.onError!(new Error('Only simple MemberExpressions are supported.'))
222+
return undefined
201223
}
202224

203-
const target = this.#parseExpression(object, scope)
204-
try {
205-
let parent = target
206-
const member = path.reduce((value, next) => {
207-
parent = value
208-
return value[next]
209-
}, target)
210-
if (typeof member === 'function') return member.bind(parent)
225+
if (object === null || object === undefined) {
226+
if (expression.optional) throw new NullishShortCircuit()
227+
}
211228

212-
return member
213-
} catch {
214-
const name = (object as AcornJSX.MemberExpression)?.name || 'unknown'
215-
this.props.onError!(new Error(`Unable to parse ${name}["${path.join('"]["')}"]}`))
229+
let member
230+
try {
231+
member = object[property]
232+
} catch (error) {
233+
this.props.onError!(new Error(`The property '${property}' could not be resolved on the object '${object}'.`))
234+
return undefined
216235
}
236+
if (typeof member === 'function') return member.bind(object)
237+
return member
217238
}
218239

219240
#parseName = (element: AcornJSX.JSXIdentifier | AcornJSX.JSXMemberExpression): string => {

source/types/acorn-jsx.d.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ declare module 'acorn-jsx' {
8888
type: 'CallExpression';
8989
arguments: Expression[];
9090
callee: Expression;
91+
optional: boolean;
9192
}
9293

9394
export interface ConditionalExpression extends BaseExpression {
@@ -122,10 +123,15 @@ declare module 'acorn-jsx' {
122123
export interface MemberExpression extends BaseExpression {
123124
type: 'MemberExpression';
124125
computed: boolean;
126+
optional: boolean;
125127
name?: string;
126-
object: Literal | MemberExpression;
127-
property?: MemberExpression;
128-
raw?: string;
128+
object: Expression;
129+
property: Expression;
130+
}
131+
132+
export interface ChainExpression extends BaseExpression {
133+
type: 'ChainExpression';
134+
expression: MemberExpression | CallExpression;
129135
}
130136

131137
export interface ObjectExpression extends BaseExpression {
@@ -155,9 +161,9 @@ declare module 'acorn-jsx' {
155161

156162
export type Expression =
157163
JSXAttribute | JSXAttributeExpression | JSXElement | JSXExpressionContainer |
158-
JSXSpreadAttribute | JSXFragment | JSXText |
164+
JSXSpreadAttribute | JSXFragment | JSXText | ChainExpression | MemberExpression |
159165
ArrayExpression | BinaryExpression | CallExpression | ConditionalExpression |
160-
ExpressionStatement | Identifier | Literal | LogicalExpression | MemberExpression |
166+
ExpressionStatement | Identifier | Literal | LogicalExpression |
161167
ObjectExpression | TemplateElement | TemplateLiteral | UnaryExpression |
162168
ArrowFunctionExpression
163169

0 commit comments

Comments
 (0)