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

fix(types): type CSS custom properties against what both platforms honour#423
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/vars-descriptor-types

Conversation

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor

Problem

vars() and <VariableContextProvider /> are declared twice — once in src/web/api.tsx as Record<string, string | number>, once in src/native/api.tsx and src/native-internal/variables.tsx as Record<string, StyleDescriptor>. Neither is the set of values both implementations honour, and a consumer only ever sees the web one.

One set of declarations reaches both platforms

The . export has no react-native condition. Its types in both the import and require branches point at dist/typescript/…/src/index.d.ts, which re-exports ./runtime, which re-exports ./web. dist/typescript/…/src/runtime.native.d.ts is built and shipped, but nothing selects it — reaching a .native declaration needs moduleSuffixes, and neither Expo nor React Native sets it.

The clearest demonstration is inside this repo. src/__tests__/native/vars.test.tsx is a native test that imports vars from react-native-css/runtime, and jest resolves that to the native runtime while tsc resolves it to the web declarations. On main, with this branch's two new cases in that file, they pass under jest and fail under tsc:

src/__tests__/native/vars.test.tsx(47,21): error TS2322: Type 'string[]' is not assignable to type 'string | number'.
src/__tests__/native/vars.test.tsx(68,21): error TS2322: Type 'undefined' is not assignable to type 'string | number'.

Same file, same import, two answers. That is the whole bug in one artifact: the native runtime resolves both calls end to end, and the type a consumer is handed says they are errors.

The web declaration is too narrow

vars({ "--font-stack": ["Inter", "Helvetica"] }) and vars({ "--color": undefined }) are type errors on the shared entry, and the only way through is a cast. Both work on native.

The native declaration is too wide for web to serialise

StyleDescriptor admits undefined and the StyleFunction tuples the compiler emits for var() and rgba(). Measured against main's web vars():

value web vars() on main
undefined throws Cannot read properties of undefined (reading 'toString')
null throws Cannot read properties of null (reading 'toString')
[{}, "var", ["y"]] (a StyleFunction) {"--x": "[object Object],var,y"}

A consumer importing from react-native-css/native is handed a type that permits all three.

VariableContextProvider never serialises

It spreads the raw JS values into the style object of its <div>. Measured on main:

value style object on main this branch
{ "--font-stack": ["Inter", "Helvetica"] } --font-stack is an Array "Inter,Helvetica"
{ "--list": [1, ["a", true]] } --list is a nested Array "1,a,true"
{ "--list": ["a", undefined, "b"] } --list is an Array with a hole "a,b"
{ "--number": 1, "--boolean": true } 1 and true, unstringified "1" and "true"

The README documents an API that is not exported

It shows import { VariableContext } from 'react-native-css' and <VariableContext values={…}>. src/web/api.tsx — which is what react-native-css resolves to for types — exports VariableContextProvider and does not export VariableContext at all, and the prop is value, not values.

Fix

One CustomPropertyValue type in src/runtime.types.ts, and both planes written against it:

export type CustomPropertyValue =
  | string | number | boolean | undefined | CustomPropertyValue[];

It is the set of values both implementations honour. An array is a comma-separated CSS list — a font-family stack, a transition-property list — which is what the web serialisation already produced for a flat array and what the native resolver keeps structured until the declaration reading var() consumes it. A value whose parts are space-separated (a box-shadow, a transform) is a single string. undefined leaves the property unset on both, so an ancestor's value inherits.

On web, serializeCustomProperty / toCustomProperties do the serialisation explicitly and are shared by vars() and the provider, which previously had two different behaviours for the same input.

This narrows the native declarations by StyleFunction and null. Those are the compiler's internal encoding with no web serialisation, so they do not belong in a type that ships to both platforms — but it is the one part of this that could break someone, and it is the part to push back on if you disagree.

The README section is corrected to the component and prop that exist, and states the value contract.

Which plane

Both, plus the shared type — that is the point of the change rather than an accident of it. src/runtime.types.ts holds the type; src/web/api.tsx gains the serialisation; src/native/api.tsx and src/native-internal/variables.tsx narrow their signatures with no runtime change. The compiler is not involved.

Tests

7 runtime tests plus a type fixture. 4 of the 7 fail on main, and the split is the finding:

  • src/__tests__/web/variables.test.tsx — 5 new tests over VariableContextProvider. 4 fail on main (the four rows in the table above). The fifth, undefined leaves the property unset, passes on main because an undefined in the style object reads the same as an absent key.
  • src/__tests__/native/vars.test.tsx — 2 new tests, an array reaching fontVariant as an array and an undefined letting a class's value through. Both pass on main. That is not a gap — it is the claim: native already honoured these values, and the type said otherwise. They are what makes the widened type honest rather than optimistic, and they are the runtime half of the tsc-vs-jest divergence above.
  • src/__tests__/_custom-property-value.types.ts — the compile-time half, run by the existing yarn typecheck with no new tsconfig. Jest skips it via the existing testPathIgnorePatterns: [".*/_[a-zA-Z]"]. It asserts the parity invariant (vars and the provider have the same parameter on react-native-css, /native and /web), what the type accepts, and what it must keep rejecting — null, a plain object, a StyleFunction, and StyleDescriptor as a whole.

It is mutation-proven: dropped onto main unchanged, yarn typecheck fails with 14 errors — 7 from the fixture (starting with Module '"react-native-css"' has no exported member 'CustomPropertyValue' and then six Type 'false' does not satisfy the constraint 'true'), plus 2 from the native test file and 5 from the web one. On this branch yarn typecheck exits 0.

Full suite, typecheck and lint measured against a pristine-main baseline on the same machine, same worktree layout — no new failures. main is 1048 passed / 3 failed (the two src/__tests__/babel/* suites, which fail identically at every ref on Windows); this branch is 1055 passed / 3 failed. yarn typecheck and yarn lint exit 0 on both.

KNOWN LIMITS

The web tests assert the style object, not what a browser paints. React DOM coerces a custom property through style.setProperty, so a number and a flat array already reach CSS as the right token stream on main. The differences that survive into a real browser are narrower than the table suggests, and there are two. A boolean is cleared rather than set — react-dom 19.1.0's setValueForStyle branches null == value || "boolean" === typeof value || "" === value into style.setProperty(styleName, ""), so true becomes nothing on main where this branch makes it "true". And an undefined member inside an array stringifies to an empty item — ["a", undefined, "b"].toString() is "a,,b", not "a,b". The rest of the value of serialising explicitly is that the two planes now agree by construction instead of by the DOM's coercion happening to match.

null is still accepted at runtime on web, and now stringifies instead of throwing. It is outside CustomPropertyValue, so a typed consumer cannot reach it, but an untyped one gets "--x": "null" where main threw. I would rather that than a throw; say so if you would rather it dropped like undefined.

This does not make the native declarations reachable. It makes the one declaration a consumer actually gets correct for both platforms. Shipping genuinely per-platform types would mean a react-native export condition on ., which is a much larger change and a separate conversation.


Overlaps with open PRs. Measured with git merge-tree against every open PR head — no hard conflicts, but three files are shared:

Whichever lands second needs at most a trivial rebase.

`react-native-css` resolves to the web declarations on every platform.
`.` carries no `react-native` export condition and nothing in the Expo
or React Native toolchain sets `moduleSuffixes`, so TypeScript follows
`index.d.ts` to `runtime.d.ts` and never to `runtime.native.d.ts` —
measured under `bundler`, `bundler` + `customConditions: ["react-native"]`
and `nodenext` alike.

That made the web signature the only one a consumer sees, and it was
narrower than the runtime that actually executes on native:
`vars({ "--font-stack": ["Inter", "Helvetica"] })` is a type error on a
call the native runtime resolves end to end, and the only way through
is a cast. It was also wider on the native declaration than web can
serialise: `StyleDescriptor` admits `undefined`, which threw
`Cannot read properties of undefined (reading 'toString')` on web.

Both planes are now written against one `CustomPropertyValue` — the set
of values both implementations honour. An array is a comma-separated CSS
list, which is what the web serialisation already produced for a flat
array and what the native resolver keeps structured until the
declaration reading `var()` consumes it. `undefined` leaves the property
unset on both, so an ancestor's value inherits, rather than throwing.

This narrows the native declarations by the `StyleFunction` tuples the
compiler emits for `var()` and `rgba()`. Those are an internal encoding
with no web serialisation, and passing one produced
`"[object Object],var,y"` there, so they do not belong in a public type
that ships to both platforms.

The README's variable section names the component and prop that exist,
and states the value contract the new type carries.
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