Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
680 changes: 341 additions & 339 deletions package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -121,18 +121,16 @@ describe('React Styles in Component', () => {
expect(jsFile?.content).toContain(`align-self: center`)
})

it('should throw error when a state is being refered in generated StyledJSX ', async () => {
it('should resolve a state reference in StyledJSX to an inline style', async () => {
const styledJSXGenerator = createReactComponentGenerator({
variation: ReactStyleVariation.StyledJSX,
})
try {
await styledJSXGenerator.generateComponent(ComponentWithInvalidStateStyles)
expect(true).toBe(false)
} catch (e) {
expect(e.message).toContain(
'Error running transformDynamicStyles in reactStyledJSXChunkPlugin'
)
}
const result = await styledJSXGenerator.generateComponent(ComponentWithInvalidStateStyles)
const jsFile = findFileByType(result.files, FileType.JS)

expect(jsFile).toBeDefined()
expect(jsFile?.content).toContain('backgroundColor: active')
expect(jsFile?.content).toContain(`height: \${props.config.height}`)
})

it('should explicitly send prop if style is using one prop variable', async () => {
Expand All @@ -159,18 +157,18 @@ describe('React Styles in Component', () => {
expect(jsFile?.content).toContain('<ComponentWithAttrPropContainer {...props}')
})

it('should throw error when a state is being refered in generated StyledComponents ', async () => {
it('should resolve a state reference in StyledComponents to an inline style', async () => {
const styledComponentsGenerator = createReactComponentGenerator({
variation: ReactStyleVariation.StyledComponents,
})
try {
await styledComponentsGenerator.generateComponent(ComponentWithInvalidStateStyles)
expect(true).toBe(false)
} catch (e) {
expect(e.message).toContain(
'Error running transformDynamicStyles in reactStyledComponentsPlugin'
)
}
const result = await styledComponentsGenerator.generateComponent(
ComponentWithInvalidStateStyles
)
const jsFile = findFileByType(result.files, FileType.JS)

expect(jsFile).toBeDefined()
expect(jsFile?.content).toContain('backgroundColor: active')
expect(jsFile?.content).toContain('height: props.config.height')
})
})

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { createReactComponentGenerator } from '../../src'
import {
ComponentUIDL,
GeneratedFile,
FileType,
ReactStyleVariation,
} from '@teleporthq/teleport-types'
import { dynamicNode, staticNode, component, elementNode } from '@teleporthq/teleport-uidl-builders'

const findFileByType = (files: GeneratedFile[], type: string = FileType.JS) =>
files.find((file) => file.fileType === type)

/**
* A dynamic STYLE value bound to an object PROP field (a prop reference with a
* refPath) must be emitted as an INLINE `style={{ ... }}` expression that keeps
* the full member access — NOT left to the styled-jsx `${props.x}` interpolation,
* which only uses `content.id` and would drop the refPath (producing
* `props.product` instead of `props.product.color`).
*/
describe('Dynamic style values → inline style', () => {
const ComponentWithObjectPropStyle: ComponentUIDL = component(
'ComponentWithObjectPropStyle',
elementNode('container', {}, [], undefined, {
backgroundColor: dynamicNode('prop', 'product', ['color']),
alignSelf: staticNode('center'),
}),
{
product: {
type: 'object',
defaultValue: {},
},
}
)

it('inlines an object-prop-field style (prop + refPath) with the full path', async () => {
const generator = createReactComponentGenerator({ variation: ReactStyleVariation.StyledJSX })
const result = await generator.generateComponent(ComponentWithObjectPropStyle)
const jsFile = findFileByType(result.files, FileType.JS)

expect(jsFile).toBeDefined()
// The full object-field access is inlined (optional-chained), keeping refPath.
expect(jsFile?.content).toContain('style={{')
expect(jsFile?.content).toContain("props.product?.['color']")
// …and the styled-jsx CSS block does NOT carry a broken `props.product`
// background-color declaration (which would drop the `.color` refPath).
expect(jsFile?.content).not.toContain(`background-color: \${props.product}`)
// The static sibling still goes through the shared CSS class.
expect(jsFile?.content).toContain('align-self: center')
})

it('keeps a SIMPLE prop style (no refPath) on the styled-jsx interpolation path', async () => {
const ComponentWithSimplePropStyle: ComponentUIDL = component(
'ComponentWithSimplePropStyle',
elementNode('container', {}, [], undefined, {
color: dynamicNode('prop', 'textColor'),
}),
{
textColor: {
type: 'string',
defaultValue: 'black',
},
}
)
const generator = createReactComponentGenerator({ variation: ReactStyleVariation.StyledJSX })
const result = await generator.generateComponent(ComponentWithSimplePropStyle)
const jsFile = findFileByType(result.files, FileType.JS)

expect(jsFile).toBeDefined()
// Simple prop stays a styled-jsx interpolation inside the <style jsx> block.
expect(jsFile?.content).toContain(`\${props.textColor}`)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,61 @@ describe('createBinaryExpression', () => {
prefix: true,
})
})

// A rendering condition exists to HIDE a node — it must never be able to throw.
// The bound value is nullish far more often than it looks (an optional CMS
// column, a category with no image, an unhydrated state), and a bare
// `x.length` / `x.includes(...)` took whole pages down with
// "Cannot read properties of null (reading 'length')".
describe('collection operators are null-safe', () => {
const codeOf = (
condition: { operation: string; operand?: string; containsField?: string },
type = 'array'
) => generate(createBinaryExpression(condition, { key: 'imageUrl', type }) as types.Node).code

it('isNotEmpty / isEmpty coerce a nullish value to an empty collection', () => {
expect(codeOf({ operation: 'isNotEmpty' })).toBe('(imageUrl || []).length > 0')
expect(codeOf({ operation: 'isEmpty' })).toBe('(imageUrl || []).length === 0')
})

it('objects go through Object.keys on a non-null target', () => {
expect(codeOf({ operation: 'isNotEmpty' }, 'object')).toBe(
'Object.keys(imageUrl || {}).length > 0'
)
})

it('length comparisons guard the same way', () => {
expect(codeOf({ operation: 'lengthGreaterThan', operand: '2' })).toBe(
'(imageUrl || []).length > 2'
)
})

it('contains / notContains guard the receiver of includes()', () => {
expect(codeOf({ operation: 'contains', operand: 'a' })).toBe('(imageUrl || []).includes("a")')
expect(codeOf({ operation: 'notContains', operand: 'a' })).toBe(
'!(imageUrl || []).includes("a")'
)
})

it('hasKey passes a non-null receiver to hasOwnProperty', () => {
expect(codeOf({ operation: 'hasKey', operand: 'a' }, 'object')).toBe(
'Object.prototype.hasOwnProperty.call(imageUrl || {}, "a")'
)
})

it('a non-empty string still reads as non-empty (string semantics preserved)', () => {
// eslint-disable-next-line no-eval
const evaluate = (expr: string, imageUrl: unknown) =>
// tslint:disable-next-line function-constructor
new Function('imageUrl', `return ${expr}`)(imageUrl)

const expression = codeOf({ operation: 'isNotEmpty' })
expect(evaluate(expression, 'https://cdn/x.png')).toBe(true)
expect(evaluate(expression, '')).toBe(false)
expect(evaluate(expression, null)).toBe(false)
expect(evaluate(expression, undefined)).toBe(false)
})
})
})

describe('createConditionIdentifier', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@ describe('URLSearchParamSync.buildUrlWriteBackEffect', () => {
expect(code).toContain('__nextQuery["search-keyword"] = String(q)')
expect(code).not.toContain('__nextQuery.search-keyword')
})

it('also deletes the key when the value equals a non-empty default (clean canonical URL)', () => {
const effect = buildUrlWriteBackEffect(
'sortBy',
types.identifier('sortBy'),
types.identifier('sortBy'),
types.stringLiteral('name-asc')
)
const code = codeOf(effect)

// The default now also routes to the delete branch, so loading the page at
// its default sort never writes a sticky `?sortBy=name-asc`.
expect(code).toContain('if (sortBy === "" || sortBy == null || sortBy === "name-asc")')
expect(code).toContain('delete __nextQuery.sortBy')
expect(code).toContain('__nextQuery.sortBy = String(sortBy)')
})

it('omits the default clause when no default is supplied (byte-identical fallback)', () => {
const withoutDefault = codeOf(
buildUrlWriteBackEffect('sortBy', types.identifier('sortBy'), types.identifier('sortBy'))
)
expect(withoutDefault).toContain('if (sortBy === "" || sortBy == null)')
expect(withoutDefault).not.toContain('=== "name-asc"')
})
})

describe('URLSearchParamSync.buildUrlReadBackEffect', () => {
Expand All @@ -89,4 +113,22 @@ describe('URLSearchParamSync.buildUrlReadBackEffect', () => {
expect(code).toContain('const __urlValue = router.query["search-keyword"]')
expect(code).toContain('}, [router.query["search-keyword"], router.isReady])')
})

it('resolves an absent/empty URL value to a non-empty default rather than ""', () => {
const effect = buildUrlReadBackEffect('sortBy', 'setSortBy', types.stringLiteral('name-asc'))
const code = codeOf(effect)

// The normalized (string | string[] | undefined) value falls through to the
// default when empty — this is what stops the default from being clobbered
// to "" on first load (which forced an extra unsorted fetch).
expect(code).toContain('|| "name-asc"')
expect(code).toContain('setSortBy(prev => prev === __nextValue ? prev : __nextValue)')
})

it('emits no default fallback when none is supplied (byte-identical fallback)', () => {
const code = codeOf(buildUrlReadBackEffect('sortBy', 'setSortBy'))
expect(code).not.toContain('name-asc')
// The bare normalized expression still ends in the "" missing-key fallback.
expect(code).toContain('const __nextValue =')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,18 @@ const stringifyOperandValue = (
return String(value)
}

/**
* Collection operators dereference `.length` / `.includes` on the bound value,
* which is nullish far more often than it looks (an optional CMS column, a
* category without an image, a not-yet-hydrated state). Coercing to an empty
* collection first keeps a hide-this-node condition from throwing
* `Cannot read properties of null` mid-render. A non-empty string stays
* truthy, so string semantics are preserved. Mirrors `createNullSafeCollection`
* in the JSX handler — keep the two in sync.
*/
const nullSafeCollection = (identifier: string, empty: '[]' | '{}' = '[]') =>
`(${identifier} || ${empty})`

const stringifyConditionalExpression = (
identifier: string,
operation: string,
Expand All @@ -238,39 +250,49 @@ const stringifyConditionalExpression = (
) => {
// Array/object operators
if (operation === 'isEmpty') {
return `${identifier}.length === 0`
return `${nullSafeCollection(identifier)}.length === 0`
}
if (operation === 'isNotEmpty') {
return `${identifier}.length > 0`
return `${nullSafeCollection(identifier)}.length > 0`
}
if (operation === 'lengthEquals') {
return `${identifier}.length === ${stringifyOperandValue(value)}`
return `${nullSafeCollection(identifier)}.length === ${stringifyOperandValue(value)}`
}
if (operation === 'lengthGreaterThan') {
return `${identifier}.length > ${stringifyOperandValue(value)}`
return `${nullSafeCollection(identifier)}.length > ${stringifyOperandValue(value)}`
}
if (operation === 'lengthLessThan') {
return `${identifier}.length < ${stringifyOperandValue(value)}`
return `${nullSafeCollection(identifier)}.length < ${stringifyOperandValue(value)}`
}
if (operation === 'contains') {
const operandStr = stringifyOperandValue(value)
if (containsField) {
return `${identifier}.some(item => item.${containsField} === ${operandStr})`
return `${nullSafeCollection(
identifier
)}.some(item => item.${containsField} === ${operandStr})`
}
return `${identifier}.includes(${operandStr})`
return `${nullSafeCollection(identifier)}.includes(${operandStr})`
}
if (operation === 'notContains') {
const operandStr = stringifyOperandValue(value)
if (containsField) {
return `!${identifier}.some(item => item.${containsField} === ${operandStr})`
return `!${nullSafeCollection(
identifier
)}.some(item => item.${containsField} === ${operandStr})`
}
return `!${identifier}.includes(${operandStr})`
return `!${nullSafeCollection(identifier)}.includes(${operandStr})`
}
if (operation === 'hasKey') {
return `Object.prototype.hasOwnProperty.call(${identifier}, ${stringifyOperandValue(value)})`
return `Object.prototype.hasOwnProperty.call(${nullSafeCollection(
identifier,
'{}'
)}, ${stringifyOperandValue(value)})`
}
if (operation === 'notHasKey') {
return `!Object.prototype.hasOwnProperty.call(${identifier}, ${stringifyOperandValue(value)})`
return `!Object.prototype.hasOwnProperty.call(${nullSafeCollection(
identifier,
'{}'
)}, ${stringifyOperandValue(value)})`
}

// Standard operators
Expand Down
Loading
Loading