Skip to content

Theme changes are missed by frozen (react-freeze) subtrees in JS-resolved paths — withUniwind, accent props, useUniwind (Pro 1.6.0 + OSS 1.11.0) #648

Description

@invivek26

Summary

Theme changes that fire while a subtree is frozen by react-freeze (react-navigation freezeOnBlur, manual <Freeze>) are permanently missed by every JS-resolved styling path — withUniwind, accent props (tintColorClassName etc.), and useUniwind — until the affected component happens to re-render or remount for an unrelated reason. Verified on uniwind-pro 1.6.0 (paying customer) and the mechanism is identical in OSS 1.11.0.

This is the report @Brentlok asked for in #466 ("If you have similar issue for oss version please create a separated issue with reproduction") — with the extra finding that Pro's suspended-trees support fixes the shadow-tree path but not the JS paths.

What works / what doesn't (observed on Pro 1.6.0, RN 0.85.3, Expo SDK 56, new arch)

Repro flow: tab A visible, tab B frozen (freezeOnBlur / <Freeze>), call Uniwind.setTheme, then focus tab B.

  • ✅ className styles on built-in components: correct on the frozen tab — the suspended-trees support from 1.0.0-rc.7 works.
  • ❌ withUniwind-wrapped components (vector icons, third-party text/number components): keep the old theme's colors.
  • ❌ Accent props (tintColorClassName on Image etc.): tinted glyphs keep the old tint. Notably the ref detach/re-attach cycle re-links the shadow node on thaw, but the stale render-time tintColor prop is what remains on screen — the relink doesn't catch the accent up to the current theme.
  • ❌ useUniwind(): returns the pre-flip theme forever.
  • ✅ useCSSVariable / useResolveClassNames: correct after thaw.

Everything stale self-repairs on the next setTheme while visible, or on remount — which makes the bug look intermittent in a real app (list recycling randomly heals some elements and not others).

Mechanism

react-freeze hides via Suspense. React tears down the hidden tree's layout effects on freeze and re-creates them on reveal without re-rendering. The three broken paths all resolve during render and subscribe in a useLayoutEffect whose body only re-subscribes:

  • hoc/withUniwind.native.tsx — generatedProps computed in render; effect only subscribes
  • hooks/useUniwind.ts — useState(Uniwind.currentTheme) snapshot; effect only subscribes
  • component accents — getAccentColor(...) in render; the missed notification is never replayed

The two hooks that heal do so because their effect body re-syncs on every run:

  • useResolveClassNames.native.ts calls recreate()
  • useCSSVariable.ts calls updateValue() (guarded by isMountRef)

The same gap also drops a change landing between initial render and the layout-effect subscribe.

Minimal reproduction

import { useState } from "react"
import { Button, Image, Text, View } from "react-native"
import { Freeze } from "react-freeze"
import { Uniwind, useCSSVariable, useUniwind } from "uniwind"

const Probe = () => {
  const { theme } = useUniwind()
  const background = useCSSVariable("--color-background")
  return (
    <View className="bg-background p-4">
      <Text className="text-foreground">className ink (heals — Pro engine)</Text>
      <Image source={someGlyph} tintColorClassName="accent-foreground" />
      {/* ^ stays on the old tint after thaw */}
      <Text className="text-foreground">useUniwind: {theme} (stays stale)</Text>
      <Text className="text-foreground">useCSSVariable: {String(background)} (heals)</Text>
    </View>
  )
}

export const Repro = () => {
  const [frozen, setFrozen] = useState(false)
  return (
    <View className="flex-1 items-center justify-center gap-4 bg-background">
      <Button title="1. freeze" onPress={() => setFrozen(true)} />
      <Button
        title="2. flip theme"
        onPress={() =>
          Uniwind.setTheme(Uniwind.currentTheme === "dark" ? "light" : "dark")
        }
      />
      <Button title="3. unfreeze" onPress={() => setFrozen(false)} />
      <Freeze freeze={frozen}>
        <Probe />
      </Freeze>
    </View>
  )
}

Press 1 → 2 → 3. Expected: everything reflects the new theme after unfreezing. Actual: the useCSSVariable line and (on Pro) className styles are correct; the tinted image and the useUniwind line keep the old theme indefinitely. Same result with two tabs + freezeOnBlur: true, flipping from the other tab. A withUniwind(AnyIcon) wrapper behaves like the tinted image.

Suggested fix

Give the broken paths the same self-heal the healthy hooks already have — re-sync in the subscription effect body (no-op guarded on first mount, mirroring useCSSVariable's isMountRef), or move these subscriptions to useSyncExternalStore, which rides the passive-effect path React deliberately keeps connected under a suspended Suspense boundary. For accents, additionally catching the value up when a shadow node re-links would cover the native side.

Versions

  • uniwind-pro: 1.6.0 (also applies to OSS uniwind 1.11.0 — hook sources identical on main)
  • react-native: 0.85.3, new architecture · Expo SDK 56 (~56.0.12) · react 19
  • react-freeze 1.0.4 · react-native-screens 4.25.2 · React Compiler enabled (relevant: element caching removes the accidental parent-re-render repair, making the staleness sticky)
  • Platform: observed on Android emulator + iOS device builds

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    invalidThis doesn't seem right

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions