Skip to content

fix(native): give a target:false config its own scratch space, and never map onto the cached props - #435

Draft
YevheniiKotyrlo wants to merge 18 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/target-false-scratch-bucket
Draft

YevheniiKotyrlo wants to merge 18 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/target-false-scratch-bucket

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A target: false config writes no style prop: nativeStyleMapping moves its declarations into real props. Until then they sit in style, because calculateProps turns the false into "style" and nativeStyleMapping reads them back from there. style is also a real target another config can own, so a component carrying both kinds of config puts both sets of declarations in one object, and main then deletes style after the mapping. Measured on main, with Labelled a View that takes labelColor and badgeColor:

configs expected main
className → style over .c1 { color: orange }, labelClassName → { color: "labelColor" } over .c2 { color: blue } style: { color: orange }, labelColor: blue style: {}, labelColor: blue
labelClassName → labelColor over .c4 { color: orange }, badgeClassName → badgeColor over .c5 { color: blue } labelColor: orange, badgeColor: blue labelColor: blue, no badgeColor

nativeStyleMapping also drains the props it maps in place, and those are the computed props cached for every render. getStyledProps hands them over once per config through adaptProps, and for React Native's Image with object-fit, adaptImageProps returns a new object on each call whose nested objects are still the cached ones. So a config that follows a mapping starts from a fresh copy and the mapped value is lost:

const TintedImage = styled(Image, {
  tintClassName: {
    target: "style",
    nativeStyleMapping: { color: "tintColor" },
  },
  className: "style",
});

// tintColor is undefined
<TintedImage source={source} className="object-contain" tintClassName="text-red-500" />;

A scratch space of its own, read without copying, would be drained in the cache the same way, so the two changes go together.

Solution

  • A target: false config collects its declarations under its own source key, which collectionTarget names for the rule variation and the mapping alike. A source is a key of the styled() mapping, so no two configs share one, and getStyledProps already strips every consumed source, so the key never ships. style then belongs only to the configs that target it, and the two delete props.style lines go. StyleRule.target takes that key as a CollectedTarget in place of false, which nothing produces now, and targetPath turns every target into a path.
  • nativeStyleMapping builds new objects along the paths it changes and leaves its input as it was, with valueAtPath, withDeepPaths and withoutKeysAt beside getDeepPath and setDeepPath in src/native/objects.ts; withDeepPaths copies each object on the way once however many mapped keys reach it. It runs once per configs and computed props object, and adaptImageProps once per computed props object it adapts, so a render that changes neither maps nothing again.
  • getStyledProps maps every config before any merges, so each merge, and each target it keeps, sees every mapping, a mapping that writes into another config's target included. Every mapping reads the props as computed, and every mapped key leaves its source before any destination is written, so no mapping reads what another wrote, in either order of the configs.
  • A prop off every config's target, at any depth, which a target: false mapping or a declaration naming its own prop writes, is settled once, after every config has merged its target, as the cascade orders it; that includes a key beside a target under the same prop, such as options.headerTintColor beside options.headerStyle. Its normal value yields to an inline one whole, unless the inline value is a falsy style, which React Native's style flattening skips; its !important value wins key by key over a plain object, after a style array, and whole over a falsy style. So the configs' order changes neither which value wins nor how two meet, and a settled object keeps its identity while the classes and the inline value do.
  • A target: false config's rules also collect the props their declarations name, such as caret-color's cursorColor, under the same key, so only a mapping ships them.

A declaration the mapping does not name is dropped, as docs/v5-engine-contracts.md says of a target: false config. The suite's own static styles w/ target none in styled.test.ios.tsx asserts that shape and still passes.

Tests

  • src/__tests__/native/components.test.tsx:
    • a style-target config keeps its declarations beside a target: false one;
    • a target: false config emits no style prop;
    • two target: false configs each keep their own declarations;
    • the configs declared in the other order give each the same declarations;
    • an !important declaration keeps to its own config;
    • a declaration no mapping names is dropped, for a mapped config and for the bare false shorthand;
    • a target: false mapping on an Image with object-fit holds on every render;
    • a target: false mapping after a style config leaves style alone on every render;
    • a mapping into an earlier config's target keeps its value on every render;
    • a target: false mapping into style keeps its value where no inline style meets it;
    • an inline style object, an inline style function, an inline style holding an Animated value, an inline ripple and an inline style array stand beside a target: false mapping into their prop, and an !important one meets the objects key by key, follows the array and leaves the function as it came; an inline style of false or null leaves the mapped value as it is, normal or !important;
    • a normal or !important mapping into a sibling of a two-key target keeps its value with the target: false config first or last, with and without inline options;
    • a memoized child renders once under an !important ripple over three renders with the same classes;
    • the built-in Button takes an !important class's color over its color prop, and its color prop over a normal class's;
    • a target: false mapping's !important value beats an inline value of its prop, alone and before or after a style config;
    • a style config's own !important prop beats its inline value beside a bare false config in either order;
    • a target: false config's normal and !important declarations mapped into one prop meet key by key, from one config, from two, into a ripple, and with a style config first or last;
    • a mapping into a key another config maps from gives the same props in either order of the configs;
    • a target: false class's declarations that name their own props, object-fit on an Image and caret-color and line clamping on a TextInput, stay under its key, and a mapping that names one ships it;
    • a memoized component under a mapping renders once over three renders with the same classes;
    • a target: false config follows its class as it is set, removed and set again;
    • useNativeCss renders a component it is given no props for.
  • src/__tests__/native/image-fit.test.tsx: a tint mapping followed by another config keeps tintColor on every render, for a normal and an !important declaration, and the same style object while its classes stay the same.
  • src/__tests__/native/objects.test.ts: withDeepPaths and withoutKeys copy what they change, keep symbol keys and leave their input untouched, and withDeepPaths writes in order and copies an object a write placed before a later write enters it; withoutKeysAt copies the path it strips and returns its input where it strips nothing; valueAtPath reads through objects and arrays; isPlainObject tells an object literal from an array, a function and a class instance.
  • src/__tests__/native/styled.test.ios.tsx: a mapping onto a nested target waits for its class, then maps on every render.

Of the 48 cases this PR adds outside objects.test.ts, which tests the new helpers, 28 fail on main; all pass here.

Verification

Every ci.yml job passes on this head merged into main with #477's harness fix: lint, typecheck, the tests (1415 passed), both builds, the web export, nothing unstaged, commitlint, and the iOS dev build on macOS 27.0.1 with Xcode 27.0, where CI pins 26.6.

A re-render of an Image with object-fit and a mapping takes 42.4 µs against 46.7 µs on main, faster in nine rounds of ten; a mapped View, a plain one and one with a target: false mapping beside an !important class and an inline value cost the same within the rounds' spread. The runs, and the device evidence before and after, are in this comment.

Known limits

  • A mapping config whose target is style shares that object with className, so its mapping also takes className's declarations, as on main.
  • A target: false config's normal mapped value loses to an inline value of a prop no other config targets whole, as on main, and an inline style function stands beside an !important value, which it takes no key from; merging the normal value key by key, and a one-key array target's !important value with its inline one, wait on refactor(native): combine two values of a style prop in one place #487's combination.

@YevheniiKotyrlo

YevheniiKotyrlo commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The subject loses its blue box. Its target:false config collects into style too, and style is deleted after the mapping, taking the style-target config with it.

FIXED — The subject keeps its blue box like the control, and its label takes the red the target:false config maps to labelColor.

before — 3.1.0-rc.0 after — with this PR

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.

The scene renders the same way on this head: rendered twice through jest, its subject keeps its box on both renders here, and on main keeps it on the first and loses it on the second, once the mapping has drained the cached props.

Re-render cost — main against this PR

rerender-cost.test.tsx: each case re-renders 3000 times after 200 of warm-up, in a jest process of its own after a warm-up case, started at high priority on the same sixteen logical processors for both sides. One warm-up round, then ten rounds, the side that goes first swapping each round. µs per re-render:

case main (eda2b286) this PR (363f16b1) per-round difference
View, className to style and a mapping of color 40.1 (39.8–41.0) 40.5 (39.7–41.0) −0.1 (−0.6 to 0.4)
plain View 38.5 (38.1–41.0) 38.9 (38.1–39.5) 0.5 (−2.1 to 1.4)
View, className to style and a target: false mapping of color from an !important class beside an inline value 43.1 (41.8–43.6) 41.5 (40.9–43.5) −0.9 (−2.1 to 0.4)
Image with object-fit and a mapping of color to tintColor 46.7 (46.1–48.4) 42.4 (39.8–43.6) −6.0 (−6.8 to −3.5)

Medians with interquartile ranges. The three View cases cost the same within the rounds' spread, the target: false one included, whose props this PR settles after the configs merge; the Image is faster in nine of the ten rounds: its adapter and its mapping now run once per computed props object rather than on every render.

Windows 11 Pro 10.0.26300, AMD Ryzen 9 9950X3D, Node 26.8.1, jest 29.7.0.

const MappedView = styled(View, {
  className: "style",
  labelClassName: { target: "style", nativeStyleMapping: { color: "accessibilityLabel" } },
});
const LabelledView = styled(View, {
  className: "style",
  labelClassName: { target: false, nativeStyleMapping: { color: "accessibilityLabel" } },
});
const TintedImage = styled(Image, {
  tintClassName: { target: "style", nativeStyleMapping: { color: "tintColor" } },
  className: "style",
});
// .faded { opacity: 0.5; } .label { color: blue; } .important { color: red !important; } .contain { object-fit: contain; }
// <LabelledView className="faded" labelClassName="important" accessibilityLabel="inline" />

…tyle`

A target:false config writes no style prop: nativeStyleMapping moves its
declarations into real props. Until then they sat in `style`, a real
target another config can own. A component carrying both kinds of config
put both sets of declarations in one object, and the style-target
config's declarations were then deleted with `style` after the mapping.

The declarations now collect under the config's own source key. No other
config shares that key, and it never ships, so `style` belongs only to
whichever config targets it and nothing deletes it. A declaration the
mapping does not name is dropped, as a config that owns no style prop
implies.
…ve the computed props untouched

nativeStyleMapping drained the computed props in place. Those props are
cached and shared by every render, and an Image with object-fit maps a copy
whose nested objects are still the cached ones, so a target:false config's
own scratch space was drained in the cache and its mapped value lost from
the second render on. The mapping now builds new objects along the paths it
changes, every config is mapped before any merges, so each merge and each
kept target sees every mapping, and one helper names where a config's
declarations collect for the rule variation and the mapping alike.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/target-false-scratch-bucket branch from f62db5f to 7f2c144 Compare October 20, 2026 11:48
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(native): a target:false config gets its own scratch space, not style fix(native): give a target:false config its own scratch space, and never map onto the cached props Oct 20, 2026
…prop with its inline value

Mapping builds new objects, so mapping on every render handed a
memoized component a new style each time. The mapped props are now
kept per configs and computed props object, and the Image adapter per
computed props object, so a render that changes neither hands React
the same objects.

A mapping into a key of another prop, such as `style.borderColor`
from a target:false config, now merges that prop with its inline value
as a config that targets the prop does, instead of losing it to an
inline `style`.

The three copies of the non-array object check use `isNonArrayObject`,
and `calculateProps` drops a branch condition that was always true.
…e an inline prop unmerged

A target:false config's rules now collect the props their declarations
name, such as caret-color's cursorColor, under the config's scratch key
with its styles, so only a mapping ships them, as the config's contract
says. A mapping into a key of another prop no longer merges that prop
with its inline value: the merge treated every prop as a style, so an
inline android_ripple became an array, an inline style function ended up
in an array, and an inline Animated value was copied.

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