Skip to content

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

Open
YevheniiKotyrlo wants to merge 4 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/vars-descriptor-types
Open

YevheniiKotyrlo wants to merge 4 commits 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. The undefined case keeps its variable (inlineVariables: false), so the class's value reaches the element only because an undefined entry plants no key.
  • 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 Ubuntu with the workflow's steps - build, lint, typecheck, yarn test, node --test node/__tests__/*.mjs, both library builds, the web export, the unstaged-changes checks and commitlint - everything passes.

Device evidence, before and after: #423 (comment)

Merge order

This carries #431, which makes an undefined entry plant no key on native — the native half of what this type promises — so #431 lands first.

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 — 3.1.0-rc.0 after — with this PR

Apart from the typeface, the two frames are the same image: the change is to the declarations.

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

vars() and <VariableContextProvider /> mapped every entry of the record
they are given into the runtime's variable record, so an entry whose value
is undefined planted a key holding undefined. Variable lookup is
presence-keyed, so that key read as "set to nothing" and stopped the cascade
before the inherited value, the :root value and the var() fallback.

Both channels now build the record through toVariableRecord, which strips
the -- prefix and skips an entry with no value, the invariant the compiler
already holds. "unset" remains how a variable is deliberately cleared.
…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.
The case compiled with the default options, so the compiler inlined the
class's --color into the rule reading it and the result never depended
on how the runtime treats an undefined entry. It now keeps the
variable, so the class's value reaches the element only because an
undefined entry plants no key, and writes the colour as a hex with no
name, so both paths serialise it alike.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 16, 2026 17:50
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 16, 2026 18:10

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