Skip to content

fix(types): type CSS custom properties against what both platforms honour - #423

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/vars-descriptor-types
Open

YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/vars-descriptor-types

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Problem

vars() and <VariableContextProvider /> are typed twice, and neither type is the set of values both implementations honour:

  • Web: Record<string, string | number>.
  • Native: Record<string, StyleDescriptor>.

A consumer only ever sees the web types, because the root entry's declarations re-export ./web and nothing selects the native ones.

  • Web types are too narrow. vars({ "--font-stack": ["Inter", "Helvetica"] }) and vars({ "--color": undefined }) are type errors, though native honours both. In src/__tests__/native/vars.test.tsx, jest resolves the native runtime and tsc the web types, so the same file passes under one and fails under the other.
  • Native types are too wide for web. StyleDescriptor admits undefined, null and the compiler's var() tuples. On main, web's vars() throws on undefined and null, and stringifies a tuple as [object Object],var,y.
  • Web's provider never serialises. It spreads raw values into its <div>'s style.

Solution

CustomPropertyValue is the set both platforms honour: a string, a number, a boolean, undefined, or an array of those, which is a comma-separated list.

  • Both platforms type vars() and VariableContextProvider with it.
  • Web serialises through one function, which leaves an undefined value unset and drops an undefined list member.
  • The README shows the provider by its current name and states the type.

Tests

  • src/__tests__/_custom-property-value.types.ts is type-level and runs under yarn typecheck:
    • the root, native and web entries share one parameter type;
    • each accepted shape is accepted;
    • null, an object, a var() tuple and the whole StyleDescriptor are rejected.
  • src/__tests__/web/variables.test.tsx: web serialisation of lists, booleans and undefined, through vars() and the provider.
  • src/__tests__/native/vars.test.tsx: a list and an undefined value reach the native runtime as typed.
  • On main, 4 of the 5 web cases fail, and the native cases fail under tsc. Each of 4 mutations fails its own cases, and adding null to the type fails the typecheck.

Verification

On Windows with Node 26:

  • yarn lint clean
  • yarn typecheck clean
  • yarn test --coverage: every failure also fails on main on this machine (the four babel cases)
  • yarn build clean
  • yarn example expo export --platform web exported

Merge order

#431 changes native vars() on the same line, so whichever lands second takes both changes.

It also shares lines with #412 (__tests__/native/vars.test.tsx, native-internal/variables.tsx), #443 (__tests__/native/vars.test.tsx), #469 (__tests__/native/vars.test.tsx); whichever lands second rebases.

Base

Re-written on main (a5002c5), where native vars() declares its ViewStyle return, which this keeps.

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The same image. The narrowing is types-only on native; what the type gets wrong is read between the platforms, not between the builds.

FIXED — The string row paints on both platforms. The number row paints a 48dp stub on native and runs full width on web — one permitted value, honoured by one plane.

before — stock 3.0.7 after — with this PR

Both frames come from the same device in the same run (Android 36 emulator, 1140×2400 @ 480dpi). before is stock 3.0.7 rather than "this build minus this PR", so one unrelated difference is visible and worth naming rather than leaving you to spot it: the compiler's inlineRem defaults to 14 and our build sets it to 16, so every rem-derived length in the before frame renders at 87.5% of the after one — smaller type, tighter spacing, a shorter probe box. That is a different fix, not this one. Read the pair as the subject moving against the control the scene renders beside it, which this PR does not change.

Each frame carries a build-probe width=<dp> line — a rem-derived box that resolves differently on a patched build. The capture harness reads it off the device and refuses to save a frame whose probe disagrees with the variant it claims, so a before image cannot silently be a second after.

…nour

vars() and VariableContextProvider were typed Record<string, string | number>
on web and Record<string, StyleDescriptor> on native, and a consumer only
sees the web declarations, since the root entry's types re-export ./web. A
font-family list or an undefined entry was a type error though native takes
both, while StyleDescriptor admits var() tuples and null that web cannot
serialise: web's vars() threw on undefined and null, and its provider passed
raw values into the style.

CustomPropertyValue is the set both platforms honour: a string, a number, a
boolean, undefined, or an array of those, which is a comma-separated list.
Both platforms type vars() and VariableContextProvider with it, and web
serialises through one function that leaves an undefined value unset. A
type-level fixture holds the three entries to one type, and the README shows
the provider by its current name.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant