fix(types): type CSS custom properties against what both platforms honour - #423
Open
YevheniiKotyrlo wants to merge 1 commit into
Open
fix(types): type CSS custom properties against what both platforms honour#423YevheniiKotyrlo wants to merge 1 commit into
YevheniiKotyrlo wants to merge 1 commit into
Conversation
`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.
This was referenced Aug 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
vars()and<VariableContextProvider />are declared twice — once insrc/web/api.tsxasRecord<string, string | number>, once insrc/native/api.tsxandsrc/native-internal/variables.tsxasRecord<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 noreact-nativecondition. Itstypesin both theimportandrequirebranches point atdist/typescript/…/src/index.d.ts, which re-exports./runtime, which re-exports./web.dist/typescript/…/src/runtime.native.d.tsis built and shipped, but nothing selects it — reaching a.nativedeclaration needsmoduleSuffixes, and neither Expo nor React Native sets it.The clearest demonstration is inside this repo.
src/__tests__/native/vars.test.tsxis a native test that importsvarsfromreact-native-css/runtime, and jest resolves that to the native runtime whiletscresolves it to the web declarations. Onmain, with this branch's two new cases in that file, they pass under jest and fail undertsc: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"] })andvars({ "--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
StyleDescriptoradmitsundefinedand theStyleFunctiontuples the compiler emits forvar()andrgba(). Measured againstmain's webvars():vars()onmainundefinedCannot read properties of undefined (reading 'toString')nullCannot read properties of null (reading 'toString')[{}, "var", ["y"]](aStyleFunction){"--x": "[object Object],var,y"}A consumer importing from
react-native-css/nativeis handed a type that permits all three.VariableContextProvidernever serialisesIt spreads the raw JS values into the
styleobject of its<div>. Measured onmain:valuemain{ "--font-stack": ["Inter", "Helvetica"] }--font-stackis anArray"Inter,Helvetica"{ "--list": [1, ["a", true]] }--listis a nestedArray"1,a,true"{ "--list": ["a", undefined, "b"] }--listis anArraywith a hole"a,b"{ "--number": 1, "--boolean": true }1andtrue, 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 whatreact-native-cssresolves to for types — exportsVariableContextProviderand does not exportVariableContextat all, and the prop isvalue, notvalues.Fix
One
CustomPropertyValuetype insrc/runtime.types.ts, and both planes written against it:It is the set of values both implementations honour. An array is a comma-separated CSS list — a
font-familystack, atransition-propertylist — which is what the web serialisation already produced for a flat array and what the native resolver keeps structured until the declaration readingvar()consumes it. A value whose parts are space-separated (abox-shadow, atransform) is a single string.undefinedleaves the property unset on both, so an ancestor's value inherits.On web,
serializeCustomProperty/toCustomPropertiesdo the serialisation explicitly and are shared byvars()and the provider, which previously had two different behaviours for the same input.This narrows the native declarations by
StyleFunctionandnull. 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.tsholds the type;src/web/api.tsxgains the serialisation;src/native/api.tsxandsrc/native-internal/variables.tsxnarrow 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 overVariableContextProvider. 4 fail onmain(the four rows in the table above). The fifth,undefined leaves the property unset, passes onmainbecause anundefinedin the style object reads the same as an absent key.src/__tests__/native/vars.test.tsx— 2 new tests, an array reachingfontVariantas an array and anundefinedletting a class's value through. Both pass onmain. 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 thetsc-vs-jest divergence above.src/__tests__/_custom-property-value.types.ts— the compile-time half, run by the existingyarn typecheckwith no new tsconfig. Jest skips it via the existingtestPathIgnorePatterns: [".*/_[a-zA-Z]"]. It asserts the parity invariant (varsand the provider have the same parameter onreact-native-css,/nativeand/web), what the type accepts, and what it must keep rejecting —null, a plain object, aStyleFunction, andStyleDescriptoras a whole.It is mutation-proven: dropped onto
mainunchanged,yarn typecheckfails with 14 errors — 7 from the fixture (starting withModule '"react-native-css"' has no exported member 'CustomPropertyValue'and then sixType 'false' does not satisfy the constraint 'true'), plus 2 from the native test file and 5 from the web one. On this branchyarn typecheckexits 0.Full suite, typecheck and lint measured against a pristine-
mainbaseline on the same machine, same worktree layout — no new failures.mainis 1048 passed / 3 failed (the twosrc/__tests__/babel/*suites, which fail identically at every ref on Windows); this branch is 1055 passed / 3 failed.yarn typecheckandyarn lintexit 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 onmain. 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'ssetValueForStylebranchesnull == value || "boolean" === typeof value || "" === valueintostyle.setProperty(styleName, ""), sotruebecomes nothing onmainwhere this branch makes it"true". And anundefinedmember 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.nullis still accepted at runtime on web, and now stringifies instead of throwing. It is outsideCustomPropertyValue, so a typed consumer cannot reach it, but an untyped one gets"--x": "null"wheremainthrew. I would rather that than a throw; say so if you would rather it dropped likeundefined.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-nativeexport condition on., which is a much larger change and a separate conversation.Overlaps with open PRs. Measured with
git merge-treeagainst every open PR head — no hard conflicts, but three files are shared:rncss/types-optional-undefined) —src/runtime.types.ts. It rewrites the mapped types further down the file; this addsCustomPropertyValueabove them. They auto-merge, and they agree:CustomPropertyValuealready includesundefined.fix/color-scheme-appearance-projection) —src/native/api.tsx, different function, auto-merges.src/runtime.types.ts; fix(compiler): scope ::selection / ::placeholder declarations to the pseudo-element #411, fix: map text-align start/end to left/right for RN compatibility #397 and feat(vite): add a Vite resolver so className works outside Metro #406 also touchREADME.md. All auto-merge today.Whichever lands second needs at most a trivial rebase.