Files
zeekay db64dea69d Stacks lay their children on tracks; Wrap is where wrapping lives
`tracks` is a CSS track list along a stack's own axis, so the parent
sizes its children and none of them carries a flex prop:

  <YStack tracks="auto 1fr auto">   header, body that fills, footer
  <XStack tracks="240px 1fr">       a fixed rail and the page

On web the stack is a grid: a YStack's tracks are its rows (row flow,
one column as wide as the stack), an XStack's its columns. A prop the
author names wins over all of it. Alignment keeps its flex meaning: on
a vertical stack grid's axes swap, so propMapper renames every key it
emits (items -> justifyItems, justify -> alignContent, content ->
justifyContent, and a direct child's self -> justifySelf), which holds
in media, pseudo and variant styles alike. A child learns its parent's
axis from ComponentContext and clears it for its own children.

On native the stack stays flex and each child takes its track as flex
longhands: Nfr is flex N, a length is a fixed basis, anything else is
content-sized.

The compiler leaves a tracks stack and its direct children to the
runtime, which is where both are decided.

Wrap is a row that wraps, the one place content-sized wrapping lives.

All opt in: a stack without tracks renders as it did. Tested in
components-test (web and native), static-tests (the deopt) and a
Playwright run of the integration app that measures the boxes in dev
and in the compiled production build. TracksDemo is in the demos app
and on the stacks docs page.

Claude-Session: https://claude.ai/code/session_01BLLMyBFSvAfCt7ZGqYuiEz
2026-10-22 11:36:30 -07:00

985 lines
25 KiB
TypeScript

import * as React from 'react'
import { expect, test } from 'vitest'
import { extractForWeb } from './lib/extract'
window['React'] = React
test('theme props get extracted properly', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test(props) {
return (
<View theme="green" width={10} bg={props.green ? 'red' : 'blue'} />
)
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
expect(output?.js).toContain(`<_GuiTheme name="green"><div className={`)
})
test('theme + media queries + conditionals extract', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test(props) {
return (
<View
theme="surface1"
$sm={{ flexDirection: 'column' }}
{...(onlyDemo && {
flexDirection: 'column',
})}
/>
)
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
expect(output?.js).toMatchSnapshot()
expect(output?.styles).toMatchSnapshot()
})
// floating && floating2 && p2 pb18 mr2 btrr10 br5 btlr7
// floating && !floating2 && p2 pb18 mr1 btrr10 br5
// !floating && floating2 && p2 pb15 mr2 btrr10 br2 btlr7
// !floating && !floating2 && p2 pb15 mr1 btrr10 br2
test('conditional specific after generic style overrides', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test(props) {
return (
<View
p="$2" // base padding
pb={floating ? 18 : 15} // should override bottom
mr={floating2 ? 2 : 1} // unrelated ternary
borderTopRightRadius={10} // base tr radius
borderRadius={floating ? 5 : 2} // should override the tr radius always
{...floating2 && {
borderTopLeftRadius: 7
}}
/>
)
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
expect(output?.js).toMatchSnapshot()
expect(output?.styles).toMatchSnapshot()
})
test('conditional styles get full base styles merged onto + shorthand', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test(props) {
return (
<View width={10} bg={props.green ? 'red' : 'blue'} />
)
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
expect(output?.js).toMatchSnapshot()
expect(output?.styles).toMatchSnapshot()
})
test('className + conditional styles get full base styles merged onto + shorthand', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test(props) {
return (
<View width={10} bg={props.green ? 'red' : 'blue'} className={props.className} />
)
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
expect(output?.js).toMatchSnapshot()
expect(output?.styles).toMatchSnapshot()
})
test('font classNames are extracted properly', async () => {
// one sanity check debug output test
const output = await extractForWeb(
`
import { Text } from '@hanzogui/core'
export function Test(props) {
return (
<Text fontFamily="$body" />
)
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
expect(
output?.js.includes(
`_cn = "font_body _dsp-inline _bxs-border-box _ww-break-word _whiteSpace-pre-wrap _mt-0px _mr-0px _mb-0px _ml-0px _ff-f-family"`
)
)
})
test('ternaries + font families works', async () => {
// one sanity check debug output test
const output = await extractForWeb(
`
import { Text } from '@hanzogui/core'
export function Test(props) {
return (
<Text fontFamily={window ? "$body" : "$heading"} />
)
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
expect(output?.js).toMatchSnapshot()
expect(output?.styles).toMatchSnapshot()
})
test('bails from non-deterministic values', async () => {
// one sanity check debug output test
const output = await extractForWeb(
`
import { Text } from '@hanzogui/core'
export function Test(props) {
return (
<Text
color={Math.random()}
/>
)
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
expect(output?.js).toMatchSnapshot()
})
test('non-flattened works', async () => {
const output = await extractForWeb(
`
import { Text } from '@hanzogui/core'
export function Test(props) {
return (
<Text
textAlign={inMenu ? props.hello : 'right'}
width="100%"
hoverStyle={{
o: 0.85,
}}
{...(active && {
fow: '700',
opacity: 1,
})}
/>
)
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
expect(output?.js).toMatchSnapshot()
})
test('fontFamily shorthand + styled + flatten works', async () => {
const output = await extractForWeb(
`
import { MySizableText } from '@hanzogui/test-design-system'
export function Test(props) {
return (
<MySizableText
ff="$mono"
/>
)
}
`
)
expect(output?.js).toMatchSnapshot()
})
test('fontFamily shorthand + styled + flatten + ternaries', async () => {
const output = await extractForWeb(
`
import { MySizableText } from '@hanzogui/test-design-system'
export function Test(props) {
return (
<MySizableText
ff="$mono"
opacity={active ? 1 : 0.65}
/>
)
}
`
)
expect(output?.js).toMatchSnapshot()
})
test('specific className + ternary', async () => {
const output = await extractForWeb(
`
import { MySizableText } from '@hanzogui/test-design-system'
export function Test(props) {
return (
<MySizableText
className="test-class-name"
ff="$mono"
opacity={active ? 1 : 0.65}
/>
)
}
`
)
expect(output?.js).toMatchSnapshot()
})
test('spread + className', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test(props) {
return (
<View
className="ease-in-out-top"
backgroundColor="#000"
paddingVertical={2}
top={0}
{...(props.conditional && {
top: -14,
backgroundColor: '#fff',
})}
>
{child}
</View>
)
}
`
)
expect(output?.js).toMatchSnapshot()
})
test('double ternary + spread', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test({ isSettings, isVertical, children }) {
return (
<View
flex={isSettings || isVertical ? 'unset' : 5}
alignItems="center"
{...(isVertical && {
flexDirection: 'column',
alignItems: 'flex-start',
})}
>
{children}
</View>
)
}
`
)
expect(output?.js).toMatchSnapshot()
})
test(`conditional classname keeps base and concats properly`, async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test(props) {
return (
<View
flex={1}
className={isEnabled ? '' : 'disable-all-pointer-events'}
>
{props.child}
</View>
)
}
`
)
expect(output?.js).toMatchSnapshot()
})
// https://github.com/hanzoai/gui/issues/3608
test('flexBasis: 0 with responsive style extracts correctly', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test() {
return (
<View
fb={1}
$gtXs={{ fb: 0 }}
/>
)
}
`
)
// fb: 0 should extract as 0px, not auto
expect(output?.styles).toContain('_fb-_gtXs_0px')
expect(output?.styles).not.toContain('_fb-_gtXs_auto')
expect(output?.js).toMatchSnapshot()
expect(output?.styles).toMatchSnapshot()
})
test('$group- styles extract to atomic CSS', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test() {
return (
<View group="card">
<View
width={100}
$group-card={{ backgroundColor: 'red' }}
/>
</View>
)
}
`
)
// child View should fully flatten to <div> with className — no runtime $group-card prop
expect(output?.js).toContain('div')
expect(output?.js).not.toContain('$group-card')
// parent's static `group="card"` should emit container CSS + `t_group_card` className.
expect(output?.styles).toContain('container-name: card')
expect(output?.styles).toContain('container-type: inline-size')
expect(output?.js).toContain('t_group_card')
// child rule rides off the parent's `.t_group_card` className.
expect(output?.styles).toContain('.t_group_card')
expect(output?.styles).toContain('background-color')
})
test('$group- styles with pseudo extract to parent-hover selector', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test() {
return (
<View group="row">
<View
width={100}
$group-row-hover={{ backgroundColor: 'red' }}
/>
</View>
)
}
`
)
expect(output?.js).toContain('div')
expect(output?.js).not.toContain('$group-row-hover')
// hover pseudo is matched off the parent's `.t_group_row` class — wrapped in
// @media (hover:hover) so touch devices don't sticky-trigger.
expect(output?.styles).toContain('.t_group_row:hover')
expect(output?.styles).toContain('@media (hover:hover)')
expect(output?.styles).toContain('background-color')
})
test('$group- with untilMeasured on the same parent does NOT extract child group styles', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test() {
return (
<View group="card" untilMeasured="hide">
<View
width={100}
$group-card={{ backgroundColor: 'red' }}
/>
</View>
)
}
`
)
// child's $group-card must remain inline for runtime measurement gating
expect(output?.js).toContain('$group-card')
// parent still extracts its container CSS so siblings without untilMeasured
// can still use it — only the descendants' group styles bail.
})
test('$group- styles on an animated element stay on the runtime path (never extract to CSS)', async () => {
// Q2 invariant: a static @container class can't drive a JS animation driver's
// interpolation, so an animated element's $group- style must NOT extract to CSS.
// the compiler enforces this via createExtractor's `animation` de-opt (the whole
// element drops to runtime), backed by extractToClassNames' animation guard.
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test() {
return (
<View group="card">
<View
width={100}
animation="bouncy"
$group-card={{ backgroundColor: 'red' }}
/>
</View>
)
}
`
)
expect(output?.js).toContain('$group-card')
})
test('$group- styles on an element with enterStyle stay on the runtime path', async () => {
// same Q2 invariant via a different animation surface (enterStyle). guards
// against a regression where an animated element's group style leaks into
// static @container CSS.
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test() {
return (
<View group="card">
<View
width={100}
enterStyle={{ opacity: 0 }}
$group-card={{ backgroundColor: 'red' }}
/>
</View>
)
}
`
)
expect(output?.js).toContain('$group-card')
})
test('$theme- styles extract to atomic CSS', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test() {
return (
<View
width={100}
$theme-light={{ backgroundColor: 'white' }}
$theme-dark={{ backgroundColor: 'black' }}
/>
)
}
`
)
// fully flattens to a div; styles emitted as theme-scoped rules.
expect(output?.js).toContain('div')
expect(output?.js).not.toContain('$theme-light')
expect(output?.js).not.toContain('$theme-dark')
expect(output?.styles).toContain('background-color')
expect(output?.styles).toContain('t_light')
expect(output?.styles).toContain('t_dark')
})
test('$platform-web styles are flattened on web', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test() {
return (
<View
width={100}
$platform-web={{ backgroundColor: 'red' }}
/>
)
}
`
)
// $platform-web styles SHOULD be flattened on web - the platform is known at compile time
// The component should be converted to a plain div with the styles applied
expect(output?.js).toContain('div')
expect(output?.js).not.toContain('$platform-web')
expect(output?.styles).toContain('background-color')
})
test('$platform-web transition property is preserved', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test() {
return (
<View
width={100}
$platform-web={{
transition: 'clip-path 400ms ease, transform 400ms ease',
clipPath: 'polygon(0 0, 100% 0, 100% 100%, 0 100%)',
}}
/>
)
}
`
)
// transition inside $platform-web should be preserved as a CSS property
expect(output?.styles).toContain('transition')
expect(output?.styles).toContain('clip-path')
})
test('CSS-only named transition stays flattened and emits CSS', async () => {
const output = await extractForWeb(`
import { View } from '@hanzogui/core'
export function Test(props) {
return <View transition="medium" opacity={props.active ? 1 : 0} />
}
`)
expect({ js: output?.js, styles: output?.styles }).toMatchInlineSnapshot(`
{
"js": "const _cn3 = "is_View _transition-all300mscub1822784663";
const _cn2 = "is_View _transition-all300mscub1822784663 _o-1";
const _cn = "is_View _transition-all300mscub1822784663 _o-0";
import { View } from '@hanzogui/core';
export function Test(props) {
return <div className={!props.active ? _cn : props.active ? _cn2 : _cn3} />;
}",
"styles": ":root ._transition-all300mscub1822784663{transition:all 300ms cubic-bezier(0.25, 0.1, 0.25, 1);}
:root ._o-1{opacity:1;}
:root ._o-0{opacity:0;}",
}
`)
})
// Verifies that conditional spread with runtime variable from hook inside map is correctly extracted
test('conditional spread with runtime variable preserves ternary', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
function usePathname() {
return '/blog'
}
const navLinks = [{ name: 'Blog', href: '/blog' }]
export function Header() {
const pathname = usePathname()
return (
<>
{navLinks.map((link) => {
const isActive = pathname.startsWith(link.href)
return (
<View
key={link.name}
backgroundColor="red"
{...(isActive && {
backgroundColor: 'blue',
})}
/>
)
})}
</>
)
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
// The ternary should be preserved in the output - the className should depend on isActive
expect(output?.js).toContain('isActive')
// The hook call should NOT be removed
expect(output?.js).toContain('usePathname')
// The pathname variable should be preserved
expect(output?.js).toContain('pathname')
expect(output?.js).toMatchSnapshot()
})
// Verifies that conditional spread with prop variable preserves the ternary in className
test('conditional spread with local variable preserves ternary', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test({ isActive }) {
return (
<View
backgroundColor="red"
{...(isActive && {
backgroundColor: 'blue',
})}
/>
)
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
// The ternary should be preserved - className should depend on isActive
expect(output?.js).toContain('isActive')
expect(output?.js).toMatchSnapshot()
})
// Verifies conditional spread + hoverStyle works correctly
test('conditional spread with hoverStyle preserves ternary', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test({ isActive }) {
return (
<View
backgroundColor="red"
cursor="pointer"
hoverStyle={{ backgroundColor: 'green' }}
{...(isActive && {
backgroundColor: 'blue',
})}
/>
)
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
// The ternary should be preserved - className should depend on isActive
expect(output?.js).toContain('isActive')
expect(output?.js).toMatchSnapshot()
})
// Verifies Text with hoverStyle and conditional spread preserves ternary
test('Text with hoverStyle and conditional spread preserves ternary', async () => {
const output = await extractForWeb(
`// debug
import { Text } from '@hanzogui/core'
export function Test({ isActive }) {
return (
<Text
cursor="pointer"
hoverStyle={{ color: '$color12' }}
{...(isActive && {
color: '$color12',
fontWeight: '800',
})}
>
hello
</Text>
)
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
// The ternary should be preserved - className should depend on isActive
expect(output?.js).toContain('isActive')
expect(output?.js).toMatchSnapshot()
})
test('conditional color prop keeps hoverStyle color when flattened', async () => {
const output = await extractForWeb(
`
import { Text } from '@hanzogui/core'
export function Test({ isActive, label }) {
return (
<Text
color={isActive ? '$color' : '$color11'}
hoverStyle={{ color: '$color' }}
>
{label}
</Text>
)
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
expect(output?.styles).toContain('color:var(--color11)')
expect(output?.styles).toContain(
'._col-0hover-color:hover{color:var(--color) !important;}'
)
expect(output?.js).toContain('isActive')
expect(output?.js).toContain('!isActive ?')
expect(output?.js).toContain('isActive ?')
expect(output?.js).toContain('_col-0hover-color _col-color11')
expect(output?.js).toContain('_col-0hover-color _col-color')
})
// role attribute is passed through during extraction
test('role attribute is preserved during extraction', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test() {
return <View role="button" />
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
// The output should have role="button" on the div
expect(output?.js).toContain('role=')
expect(output?.js).toContain('button')
})
// fontWeight ternary is dropped when combined with theme-token color ternary
test('ternary with mixed theme-token and non-token values preserves all props', async () => {
const output = await extractForWeb(
`
import { Text } from '@hanzogui/core'
export function Test({ isActive, label }) {
return (
<Text
fontSize="$3"
fontWeight={isActive ? '600' : '400'}
color={isActive ? '$color12' : '$color11'}
>
{label}
</Text>
)
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
// both ternary branches should include fontWeight
expect(output?.styles).toContain('font-weight:600')
expect(output?.styles).toContain('font-weight:400')
// both color values should also be present (as CSS variables for theme tokens)
expect(output?.styles).toContain('color:var(--color12')
expect(output?.styles).toContain('color:var(--color11')
expect(output?.js).toMatchSnapshot()
expect(output?.styles).toMatchSnapshot()
})
// CSS shorthand properties with embedded $variables
test('boxShadow with $variable extracts correctly', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test() {
return <View boxShadow="0 0 10px $background" />
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
// Should extract to CSS with box-shadow and var()
expect(output?.styles).toContain('box-shadow')
expect(output?.styles).toContain('var(--')
})
// Skip until RN supports border shorthand - use borderWidth/borderColor/borderStyle for cross-platform
test.skip('border with $variable extracts correctly', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test() {
return <View border="1px solid $background" />
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
// border expands to individual border props, check for color with var()
expect(output?.styles).toContain('border')
expect(output?.styles).toContain('var(--')
})
test('boxShadow with multiple $variables extracts correctly', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test() {
return <View boxShadow="0 0 10px $background, 0 0 20px $color" />
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
// Should contain multiple var() references
expect(output?.styles).toContain('box-shadow')
const varMatches = output?.styles?.match(/var\(--/g)
expect(varMatches?.length).toBeGreaterThanOrEqual(2)
})
// regression: createDOMProps unconditionally emits a (possibly empty) style
// key. without removing it after the call, Object.keys(out) iterates twice
// for what was a single non-style prop, and the same JSXAttribute is emitted
// twice in the output JSX. separately, the later attribute-rename pass that
// converts testID -> data-testid only runs when the value is statically
// evaluable, so dynamic testIDs were emitted as raw `<div testID={...}>`
// and silently dropped by React.
test('non-static testID with template literal is rewritten to data-testid', async () => {
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test({ x }: { x: string }) {
return <View testID={\`a-\${x}\`} />
}
`,
{
options: {
platform: 'web',
components: ['@hanzogui/core'],
},
}
)
// should be rewritten to data-testid (not raw testID) and only emitted once.
expect(output?.js).toContain('data-testid={`a-${x}`}')
expect(output?.js?.match(/data-testid=/g)?.length).toBe(1)
expect(output?.js?.match(/\btestID=/g) ?? []).toHaveLength(0)
})
test('a tracks stack and its direct children stay on the runtime path', async () => {
// the stack's grid, its children's flex on native and their `self` inside a
// vertical stack are decided at runtime (@hanzogui/web helpers/tracks.ts), so
// none of them may flatten. a grandchild and an element outside still do.
const output = await extractForWeb(
`
import { View } from '@hanzogui/core'
export function Test() {
return (
<View>
<View tracks="auto 1fr" width={300}>
<View alignSelf="center" width={100}>
<View data-grandchild width={50} />
</View>
</View>
<View data-outside width={20} />
</View>
)
}
`
)
expect(output?.js).toContain('<View tracks="auto 1fr" width={300}>')
expect(output?.js).toContain('<View alignSelf="center" width={100}>')
expect(output?.js).toMatch(/<div[^>]*data-grandchild/)
expect(output?.js).toMatch(/<div[^>]*data-outside/)
})