Skip to content

fix(ios): stop leaking hidden views, Old Arch color crash and misplaced shapes - #22

Merged
pioner92 merged 1 commit into
mainfrom
fix/ios-skeleton-stability
Oct 8, 2026
Merged

pioner92 merged 1 commit into
mainfrom
fix/ios-skeleton-stability

Conversation

@pioner92

@pioner92 pioner92 commented Oct 8, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes four iOS issues. Each one was reproduced before the fix and verified after it; the table says how.

Issue Root cause Fix Proof
#19, iOS part of #18 — content stays hidden after the tree changes during loading, other screens lose views (Android untouched) A child unmounted while isLoading is still hidden (isHidden = true). Fabric puts it in the recycle pool, and updateLayoutMetrics only resets hidden when displayType changes, so the next owner gets a hidden view. AutoSkeletonView overrides unmountChildComponentView:index: and restores the child before it leaves (SkeletonCore.restoreOriginalView). Device (Fabric, RN 0.78, iOS 26.5 simulator): child unmounted while loading, then three new Texts rendered elsewhere. Before: the first one is invisible. After: all three visible. Re-ran with the override disabled to confirm the repro catches it. Also checked unmounting the whole AutoSkeletonView while loading: the views rendered afterwards are all visible. Unit: SkeletonCoreTests.
#20 — crash -[__NSArray0 objectAtIndex:]: index 0 beyond bounds on Old Arch processColor ran only when global._IS_FABRIC === false, a global React Native does not define. The manager declared the prop as NSArray<UIColor>, which the Old Arch view config does not process, so raw strings reached RCTConvert UIColor, became nil, were dropped, and AnimationGradient indexed an empty array. Prop type is now UIColorArray, so the view config runs processColorArray; the JS gate is removed so colors are never processed twice; AnimationGradient falls back to the defaults when fewer than two colors arrive. Unit: AnimationGradientTests fails on the old code with the exact exception text from the issue, passes after. jest: gradientColors is never pre-processed in JS. The Old Arch view config change is proven by reading RN (getProcessorForType), not on a device: the Old Arch example renders nothing at all in this environment (Xcode 26.5 / iOS 26.5), even a screen with a single Text.
#16 — extra skeleton shapes at the top with TouchableOpacity SkeletonViewOldArch collects nested descendants, but the mask used originalView.frame, which is relative to the view's own parent. TouchableOpacity creates a stacking context, so its children really are nested. convert(bounds, to:) into skeleton coordinates. Device: Fabric app with AutoSkeletonView forced through the legacy interop (the path that produces this symptom) by temporarily renaming AutoSkeletonViewCls so the codegen crawl misses it. Before: a shape drawn above the card at the text's local offset. After applying only this change: gone. Unit: SkeletonPlaceholderMaskTests.
Registration hardening No ios.componentProvider, so registration relied on the deprecated .mm crawl; when it misses, Fabric falls back to the interop path from #16. Declared codegenConfig.ios.componentProvider. Generated RCTThirdPartyComponentsProvider.mm lists both components.

Tests

Every invariant the fixes rely on has a test, and every test that guards a fix was run red by reverting just that fix.

Invariant Test Red when reverting
Unmounting a child while loading restores its visibility AutoSkeletonViewTests.testUnmountingAChildWhileLoadingRestoresItsVisibility (hosted) the unmountChildComponentView override
…and keeps an originally hidden child hidden testUnmountingAnOriginallyHiddenChildKeepsItHidden (hosted) — (invariant)
Leaving the window while loading restores children (whole skeleton unmounted) testLeavingTheWindowWhileLoadingRestoresChildren (hosted) — (existing behaviour, now guarded)
restoreOriginalView hands a view back unhidden, keeps original state, stops managing it, ignores foreign views SkeletonCoreTests (4 cases, SwiftPM) does not compile without the method
Targets hidden while loading, ignored views never hidden, original state restored after loading SkeletonCoreTests (3 cases, SwiftPM) — (invariant)
Old Arch manager exports gradientColors as UIColorArray testOldArchGradientColorsPropIsExportedAsUIColorArray (hosted) the prop type
That exported type makes RN's real getNativeComponentAttributes convert the colors (and shimmerBackgroundColor) nativeContract.test.ts › Old Architecture view config (jest) the prop type
Old Arch setter survives unprocessed colors testOldArchSetterSurvivesUnprocessedColors (hosted) the gradient guard — throws the exact __NSArray0 exception from #20
Old Arch setter applies processed colors testOldArchSetterAppliesProcessedColors (hosted) — (invariant)
Gradient falls back to defaults on fewer than two colors AnimationGradientTests (SwiftPM) the gradient guard — same exception
Nested view shapes are drawn in skeleton coordinates SkeletonPlaceholderMaskTests (SwiftPM) the coordinate conversion
JS never pre-processes gradientColors; defaults and pass-through index.test.tsx (jest) the old _IS_FABRIC gate
componentProvider lists every codegen component and maps it to a Fabric component class nativeContract.test.ts › Fabric component registration (jest) the provider entry

How they run:

  • yarn test — jest (11).
  • yarn test:ios — ios-tests/ Swift package over ios/SkeletonView (12), no pods needed.
  • yarn test:ios:example — AutoSkeletonExampleTests, an XCTest target hosted in the example app, so the ObjC++ component and the Old Arch manager run against the real React and codegen pods (7). Classes are resolved at runtime because the library headers are not public in the podspec. IOS_DESTINATION picks the simulator, RCT_METRO_PORT the Metro port.
  • CI: new test-ios job runs both native suites. It has not been run on GitHub yet; it builds the example on the runner's Xcode, so it can hit the same fmt consteval error the example has with Xcode 26.

Android is unchanged, so no Android tests were added.

Other changes

  • import UIKit added to the Swift files that used UIKit types only through the pod's umbrella header, so the core compiles on its own for the tests.
  • Example: AutoSkeletonExampleTests target, its scheme entry, and inherit! :search_paths in the Podfile (headers only; linking the pods again would duplicate every class already in the host app). Podfile.lock only changes its PODFILE CHECKSUM.
  • CI's yarn test currently fails with "No tests found"; it now has tests.
  • yarn.lock resynced: it still listed @expo/config-plugins as a dev dependency after it moved to peers, so yarn install --immutable was already failing.

Known limitation

Under the legacy interop path, the unmountChildComponentView override does not run, so the #19 leak can still happen there. Explicit componentProvider registration makes that path unlikely.

Verification

  • yarn test 11/11, yarn test:ios 12/12, yarn test:ios:example 7/7
  • yarn typecheck, yarn lint clean (two pre-existing warnings in example/), yarn install --immutable, yarn prepare, commitlint
  • Example app (Fabric) on the simulator: the [iOS] If component unmounts while loading, it does not reappear correctly #19 repro and the original loading → content → loading → content cycle both work.

🤖 Generated with Claude Code

…ed shapes

- Restore a child's isHidden when Fabric unmounts it during loading, so the
  recycled view does not come back hidden on another screen.
- Let the Old Arch view config process gradientColors (UIColorArray) instead
  of a JS gate on global._IS_FABRIC, which React Native never defines, and
  fall back to the default colors when fewer than two arrive.
- Convert nested view frames into skeleton coordinates when building the mask.
- Declare codegenConfig.ios.componentProvider so the Fabric component is
  registered explicitly instead of through the deprecated .mm crawl.
- Add jest tests for the JS layer and the native contracts, an XCTest package
  for the Swift core (yarn test:ios) and an XCTest target hosted in the
  example app for the component and the Old Arch manager
  (yarn test:ios:example); run both native suites in a new CI job.
- Import UIKit explicitly so the Swift core builds standalone.

Fixes: #16, #19, #20
Refs: #18
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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