Skip to content

className swap after a frozen-time theme flip applies an empty style — Text paints default ink (Pro 1.6.0) #653

Description

@invivek26

What happened?

A deterministic follow-up to #649, but in the native className lane (so #650 does not cover it): after a theme flip that lands while a screen is frozen (react-freeze via native-stack/tabs), the first className swap on a node of that screen — a plain cn/tv conditional flipping between two class strings — paints with no resolved color at all. On Text that means React Native's default black ink.

Pixel evidence from the repro (iOS 26 simulator, iPhone 17 Pro): a segmented pill whose selected variant is bg-brand text-on-brand, where --color-on-brand: #ffffff in the base @theme block and both theme variants. After the frozen-time dark→light flip, tapping the never-yet-selected pill paints:

  • pill background: rgb(0,57,228) — correct brand blue (this one is resolved with useResolveClassNames into a style object for a non-uniwind native view, i.e. the pure JS lane — always correct)
  • label glyphs: rgb(0,0,1) — not dark's value, not light's value, and the token is identical in every theme block. This is RN's default text color: the style landed empty, not wrong-theme

Observed behavior matrix from the same session (all in one commit sequence, minutes after the flip, so no hydration race):

  1. Pill Y swaps to the selected string (first time this node carries it) → label black
  2. Pill X swaps to the unselected string in the same commit → fully correct (#2d2d2d, light foreground)
  3. Next swap: X back to selected → correct white; Y to unselected stays black (should be #2d2d2d)
  4. One more swap: Y back to selected → heals, correct white from then on

So the broken node stays broken through one more className change and recovers on the second — an erratic warm-up rather than a stable wrong value. Flips performed while the screen is visible don't reproduce; strings that were rendered at least once under the boot theme survive flips fine (the flip lane itself re-commits mounted classNames correctly).

Where it smells from reading the source, offered with appropriate humility:

  • HybridShadowRegistry::updateShadowTree seeds family->nativeProps_DEPRECATED for suspended/frozen families, and that stash is cleared only in UniwindStore::linkShadowNodeWithUniwindStyle — which runs from the component's ref callback, i.e. after the commit that carried the new className. The clearing comment there already mentions "timing conflicts". A stale stash merging over the swap commit (or a merge payload the RawProps parser rejects, nulling color) would produce exactly a default-ink paint.
  • For RuntimeChangeSource.User flips, updateTree is deferred behind requestIdleCallback (components/native/utils/listener.ts). We also run the updateCSSVariables(theme, {}) re-commit workaround from [Pro 1.1.0 / iOS] bg-* views stuck on previous theme after OS appearance flip + react-navigation native-stack push/pop #518 shortly after each flip, and setPendingCommit replaces any pending commit — if the idle-deferred update and the recommit interleave, half-applied state seems reachable.

Steps to Reproduce

  1. Two registered themes, with at least one color token whose value is identical in both (e.g. --color-on-brand: #ffffff in base + both variants).
  2. A tab/stack screen (frozen on blur) renders segmented pills via a tv/cn conditional: selected → bg-brand text-on-brand, unselected → bg-glass-fill text-foreground. Pill X starts selected, pill Y unselected.
  3. Cold-start the app in dark.
  4. Navigate away so the screen freezes.
  5. Uniwind.setTheme('light').
  6. Navigate back — the whole screen is correctly re-themed (the flip lane works).
  7. Tap pill Y. Its label renders with RN default black ink (no color); its background and every other node in the commit are correct.
  8. Tap X, then Y again — Y's label stays black through the first swap and heals on the second.

Sketch of the component shape (illustrative):

const pill = tv({
  slots: { label: "text-center", tab: "rounded-full px-4 py-2" },
  variants: {
    selected: {
      true: { label: "text-on-brand", tab: "bg-brand" },
      false: { label: "text-foreground", tab: "bg-glass-fill" },
    },
  },
})

const Pills = ({ value, onChange }) => (
  <View className="flex-row gap-2">
    {OPTIONS.map((option) => (
      <Pressable key={option} onPress={() => onChange(option)} className={pill().tab({ selected: option === value })}>
        <Text className={pill().label({ selected: option === value })}>{option}</Text>
      </Pressable>
    ))}
  </View>
)

Happy to run instrumented builds against our reproduction if that helps — it triggers on the first try with the steps above.

Snack or Repository Link (Optional)

No response

Uniwind version

uniwind-pro 1.6.0

React Native Version

0.85.3

Platforms

iOS

Expo

Yes

Additional information 〰

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions