Skip to content

fix(native): a target:false config gets its own scratch space, not style - #435

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/target-false-scratch-bucket
Open

fix(native): a target:false config gets its own scratch space, not style#435
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/target-false-scratch-bucket

Conversation

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor

The defect

A target: false config means "write no style prop; route these declarations into real props instead". Its declarations still need somewhere to sit between calculateProps and nativeStyleMapping, and that place is normal.style:

// src/native/styles/calculate-props.ts
const ruleTarget = rule.target || "style";   // the `false` becomes the string
// src/native/styles/index.ts — nativeStyleMapping
} else if (config.target === false) {
  source = props["style"];                   // read back by the same hardcoded name
}

The pair is deliberate — that is how the declarations travel. What is not deliberate is that style is also a real target another config can own. A component carrying both kinds of config puts both sets of declarations in one object, and each drains the other's.

const Styled = styled(Labelled, {
  className: { target: "style" },
  labelClassName: { target: false, nativeStyleMapping: { color: "labelColor" } },
});

<Styled className="c1" labelClassName="c2" />   // .c1 { color: orange } .c2 { color: blue }
style labelColor
expected { color: orange } blue
main {} blue

The style-target config's declaration is gone — drained into the other config's prop. It is order-independent, and it bites whenever the two configs name the same property.

The same shared space also ships a style prop on a config that asked for none, carrying whatever no mapping entry drained: { fontSize: 20 }, or a transform that visibly moves a component whose config said it wanted no style at all.

Why the suite is green

Button is the only shipped component with target: false, and it misses this twice over: it carries no second config to swap with, and its mapping is the identity { color: "color" }, so nesting under style and writing at the top level are indistinguishable for it. React Native's own Button then ignores the stray style prop. Every other shipped component is bare-string or { target: "style" }.

So this reaches user-defined mappings, which StyledConfiguration explicitly supports — runtime.types.ts carries a dedicated T extends false branch for nativeStyleMapping.

The fix

Give the config its own scratch space instead of sharing one:

// src/native/react/rules.ts — getRuleVariation
target: config.target === false ? [config.source] : config.target,
// src/native/styles/index.ts — nativeStyleMapping
source = props[config.source];

A source is an Object.entries(mapping) key, so two configs cannot share one. No new mechanism is introduced: calculateProps already nests an array target, and getStyledProps already strips every consumed source — a target: false config is always consumed, because config.source !== config.target holds for it.

rule.target and config.target now say different things for these configs, and that split is the point: the rule's target names which bucket the declarations go in, the config's target names which real prop the config owns. config.target is still false at every reader, and calculate-props.ts:58 is the only reader of rule.target.

This also moves native toward web, where useCssElement already resolves a boolean target to the source key.

What changes for callers

A declaration the mapping does not name is now dropped rather than moved into style. That is the intended meaning of target: false — four places already say such a config owns no style prop — but it is a real behaviour change and invisible in a two-line diff:

config main this PR
.d { transform: translateX(10px); color: orange }, mapping { color: "labelColor" } { style: { transform: [...] }, labelColor: orange } { labelColor: orange }
{ labelClassName: false } shorthand { style: { color: orange } } {}

There is a runtime escape hatch, and it does not typecheck. A dotted destination writes wherever it points, so { color: "style.color" } puts a declaration back into style — but nativeStyleMapping destinations are constrained to the component's own prop paths, and none reaches inside style. A typed caller therefore has no way to keep a declaration the mapping does not name. Worth your judgement on whether that gap should be closed, or whether dropping is simply what target: false should mean.

One collision the types do not exclude: a config whose target names another config's source. It is unreachable rather than unrepresentable — that mapping already throws in updateRules today, on main, whenever the source prop is passed, which is the same condition a scratch needs to exist at all. Filed separately rather than fixed here.

Your own spec already says this

src/__tests__/native/styled.test.ios.tsx carries a skipped test, static styles w/ target none, asserting that a target: false config with { color: "myColor" } over .text-blue-500 { color: blue; background-color: red; } renders exactly { testID, children, myColor } — unmapped background-color dropped, and no style prop.

It is skipped because it names the option nativeStyleToProp, which predates nativeStyleMapping. Renaming that one key and running it:

mismatches against the asserted shape
main the colour literal (#00f vs #0000ff), and style: { backgroundColor: "#f00" }
this PR the colour literal only

The leaked prop that test forbids is exactly what this fixes. I have not un-skipped it here — the remaining mismatch is a stale colour format from an unrelated change, and updating it is not this PR's scope — but it is your statement of what target: false should do, and this makes it true.

Tests

Six in src/__tests__/native/components.test.tsx, each written red first and each proven red again with the two lines reverted:

  • a target: false config does not take a style-target config's declarations
  • a target: false config emits no style prop
  • two target: false configs each keep their own declarations
  • declaration order does not change what either config gets
  • the important path gets its own scratch space too — nativeStyleMapping is called for normal and important, so the one changed read has two call sites
  • a declaration no mapping entry names is dropped, not moved into style

Verification

yarn typecheck and yarn lint clean. Full suite, same command both sides:

base (f70c402) this branch
passed 1048 1054
failed 3 3

Exactly +6. The 3 failures are the src/__tests__/babel/ suites, which fail identically on the unmodified base — a Windows environment issue in my checkout.

…tyle`

A `target: false` config means "write no style prop, route these declarations
into real props instead". Its declarations still need somewhere to sit between
`calculateProps` and `nativeStyleMapping`, and that place was `normal.style` —
`calculateProps` resolves the write target as `rule.target || "style"`, turning
the `false` into the string, and `nativeStyleMapping` reads it back out of
`props["style"]` by the same hardcoded name.

The pair is deliberate. What is not is that `style` is also a real target another
config can own. A component carrying both a `target: "style"` config and a
`target: false` config puts both sets of declarations in one object and each
drains the other's: with both naming `color`, the style-target config's
declaration disappears entirely and the other config's prop receives it. Order
makes no difference. The same shared space also shipped a `style` prop on a
config that asked for none, carrying whatever no mapping entry drained — a
`transform` among it, visibly moving a component that had declared it wanted no
`style` at all.

The scratch space is now the config's own source key. A source is an
`Object.entries(mapping)` key, so two configs cannot share one. No new mechanism
is introduced: `calculateProps` already nests an array target, and
`getStyledProps` already strips every consumed source — a `target: false` config
is always consumed, because `config.source !== config.target` holds for it.

`rule.target` and `config.target` now differ for these configs, which is the
point: the rule's target names which bucket the declarations go in, the config's
names which real prop the config owns. `config.target` is still `false` at every
reader, and `calculate-props.ts` is the only reader of `rule.target`. Web already
resolves a boolean target to the source key, so this moves native toward it.

Nothing shipped is affected. `Button` is the only component with `target: false`,
it carries no second config to swap with, and its mapping is the identity
`{ color: "color" }` — so nesting under `style` and writing at the top level are
indistinguishable for it. That is why 1048 tests passed either way.

A declaration the mapping does not name is now dropped rather than leaked into a
`style` prop the config declared it did not want.

Six tests, each red first and each proven red again with the fix reverted.
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