Repository navigation
fix(native): give a target:false config its own scratch space, and never map onto the cached props - #435
Draft
YevheniiKotyrlo wants to merge 17 commits into
Draft
fix(native): give a target:false config its own scratch space, and never map onto the cached props#435YevheniiKotyrlo wants to merge 17 commits into
YevheniiKotyrlo wants to merge 17 commits into
Conversation
Contributor
Author
YevheniiKotyrlo
force-pushed
the
fix/target-false-scratch-bucket
branch
from
October 11, 2026 12:36
79196bf to
f62db5f
Compare
YevheniiKotyrlo
marked this pull request as draft
October 20, 2026 09:53
…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
force-pushed
the
fix/target-false-scratch-bucket
branch
from
October 20, 2026 11:48
f62db5f to
7f2c144
Compare
style…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.
… which skips a missing style
…nd turn targets into paths once
…he mapping configs per configs
…ap from the computed props
…s a source that loses none
… then !important, key by key
…without a dynamic delete
…veCss given no props
This branch has not been deployed
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
A
target: falseconfig writes no style prop:nativeStyleMappingmoves its declarations into real props. Until then they sit instyle, becausecalculatePropsturns thefalseinto"style"andnativeStyleMappingreads them back from there.styleis also a real target another config can own, so a component carrying both kinds of config puts both sets of declarations in one object, andmainthen deletesstyleafter the mapping. Measured onmain, withLabelledaViewthat takeslabelColorandbadgeColor:mainclassName→styleover.c1 { color: orange },labelClassName→{ color: "labelColor" }over.c2 { color: blue }style: { color: orange },labelColor: bluestyle: {},labelColor: bluelabelClassName→labelColorover.c4 { color: orange },badgeClassName→badgeColorover.c5 { color: blue }labelColor: orange,badgeColor: bluelabelColor: blue, nobadgeColornativeStyleMappingalso drains the props it maps in place, and those are the computed props cached for every render.getStyledPropshands them over once per config throughadaptProps, and for React Native'sImagewithobject-fit,adaptImagePropsreturns 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: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
target: falseconfig collects its declarations under its own source key, whichcollectionTargetnames for the rule variation and the mapping alike. A source is a key of thestyled()mapping, so no two configs share one, andgetStyledPropsalready strips every consumed source, so the key never ships.stylethen belongs only to the configs that target it, and the twodelete props.stylelines go.StyleRule.targettakes that key as aCollectedTargetin place offalse, which nothing produces now, andtargetPathturns every target into a path.nativeStyleMappingbuilds new objects along the paths it changes and leaves its input as it was, withvalueAtPath,withDeepPathsandwithoutKeysAtbesidegetDeepPathandsetDeepPathinsrc/native/objects.ts;withDeepPathscopies each object on the way once however many mapped keys reach it. It runs once per configs and computed props object, andadaptImagePropsonce per computed props object it adapts, so a render that changes neither maps nothing again.getStyledPropsmaps 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.target: falsemapping or a declaration naming its own prop writes, is settled once, after every config has merged its target: its normal value, then the inline one, then the!importantone, as the cascade orders them, key by key where two of them are plain objects. An inline value no key merge can enter, such as a style function or an array, stands. So the configs' order changes neither which value wins nor how two meet.target: falseconfig's rules also collect the props their declarations name, such ascaret-color'scursorColor, under the same key, so only a mapping ships them.A declaration the mapping does not name is dropped, as
docs/v5-engine-contracts.mdsays of atarget: falseconfig. The suite's ownstatic styles w/ target noneinstyled.test.ios.tsxasserts that shape and still passes.Tests
src/__tests__/native/components.test.tsx:target: falseone;target: falseconfig emits no style prop;target: falseconfigs each keep their own declarations;!importantdeclaration keeps to its own config;falseshorthand;target: falsemapping on anImagewithobject-fitholds on every render;target: falsemapping after a style config leavesstylealone on every render;target: falsemapping intostylekeeps its value where no inline style meets it;Animatedvalue and an inline ripple stand beside atarget: falsemapping into their prop, and an!importantone meets the three objects key by key and leaves the function as it came;Buttontakes an!importantclass's color over itscolorprop, and itscolorprop over a normal class's;target: falsemapping's!importantvalue beats an inline value of its prop, alone and before or after a style config;!importantprop beats its inline value beside a barefalseconfig in either order;target: falseconfig's normal and!importantdeclarations mapped into one prop meet key by key, from one config, from two, into a ripple, and with a style config first or last;target: falseclass's declarations that name their own props,object-fiton anImageandcaret-colorand line clamping on aTextInput, stay under its key, and a mapping that names one ships it;target: falseconfig follows its class as it is set, removed and set again;useNativeCssrenders a component it is given no props for.src/__tests__/native/image-fit.test.tsx: a tint mapping followed by another config keepstintColoron every render, for a normal and an!importantdeclaration, and the same style object while its classes stay the same.src/__tests__/native/objects.test.ts:withDeepPathsandwithoutKeyscopy what they change, keep symbol keys and leave their input untouched, andwithDeepPathswrites in order and copies an object a write placed before a later write enters it;withoutKeysAtcopies the path it strips and returns its input where it strips nothing;valueAtPathreads through objects and arrays;isPlainObjecttells 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 36 cases this PR adds outside
objects.test.ts, which tests the new helpers, 22 fail onmain; all pass here.Verification
Every
ci.ymljob passes on this head merged intomainwith #477's harness fix: lint, typecheck, the tests (1403 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
Imagewithobject-fitand a mapping takes 42.1 µs against 48.8 µs onmain, faster in nine rounds of ten; a mappedView, a plain one and one with atarget: falsemapping beside an!importantclass 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
styleshares that object withclassName, so its mapping also takesclassName's declarations, as onmain.target: falseconfig's normal mapped value loses to an inline value of a prop no other config targets whole, as onmain; merging the two key by key, and a one-key array target's!importantvalue with its inline one, wait on refactor(native): combine two values of a style prop in one place #487's combination.