refactor(Android, Stack v5): move invalidation flags to native implementation - #4599
refactor(Android, Stack v5): move invalidation flags to native implementation#4599kligarski wants to merge 3 commits into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (14)
💤 Files with no reviewable changes (1)
Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review. 📝 WalkthroughWalkthroughThe Android stack header now batches configuration invalidations, preserves its coordinator layout across fragment view recreation, and restores collapsed state after applicable rebuilds. New Android integration scenarios verify persistence across nested tab switches and related header updates. ChangesStack header update flow
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: ⚪ Minimal · up to The header persistence and update-flow changes have no identified merge-blocking risk. Sequence Diagram(s)sequenceDiagram
participant ReactConfig
participant StackHeaderConfig
participant StackHeaderCoordinatorLayout
participant StackHeaderApplicator
ReactConfig->>StackHeaderConfig: update header configuration
StackHeaderConfig->>StackHeaderCoordinatorLayout: send invalidation flags
StackHeaderCoordinatorLayout->>StackHeaderCoordinatorLayout: flush after pending updates end
StackHeaderCoordinatorLayout->>StackHeaderApplicator: apply flagged header changes
StackHeaderCoordinatorLayout->>StackHeaderCoordinatorLayout: restore collapsed state after rebuild
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 20.51% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 39 functions across 10 files. (3 skipped: 3 unsupported.)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
1a384fd to
7165a0c
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
The native lifecycle and state-retention changes require final human validation.
Pull request overview
Refactors Android Stack v5 header lifecycle handling to preserve headers and collapse state across tab detach/reattach cycles.
Changes:
- Moves invalidation ownership and batching into the native coordinator.
- Retains header views and preserves collapsed state across updates.
- Adds regression scenarios and updates behavior documentation.
File summaries
| File | Description |
|---|---|
src/components/stack/header/StackHeaderConfig.android.types.ts |
Documents updated scroll-flag behavior. |
apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android/scenario.md |
Updates rebuild expectations. |
apps/src/tests/single-feature-tests/stack-v5/test-stack-header-title-appearance-android/scenario.md |
Adds collapsed-header coverage. |
apps/src/tests/component-integration-tests/tabs-stack-v5/test-stack-tabs-stack-in-tabs-header-persistence/scenario.md |
Defines persistence test scenarios. |
apps/src/tests/component-integration-tests/tabs-stack-v5/test-stack-tabs-stack-in-tabs-header-persistence/scenario-description.ts |
Registers scenario metadata. |
apps/src/tests/component-integration-tests/tabs-stack-v5/test-stack-tabs-stack-in-tabs-header-persistence/index.tsx |
Implements the integration test. |
apps/src/tests/component-integration-tests/tabs-stack-v5/index.ts |
Registers the new scenario. |
android/src/main/java/com/swmansion/rnscreens/stack/screen/StackScreenFragment.kt |
Retains the header coordinator. |
android/src/main/java/com/swmansion/rnscreens/stack/header/StackHeaderCoordinatorLayout.kt |
Owns invalidations and unified flushing. |
android/src/main/java/com/swmansion/rnscreens/stack/header/StackHeaderApplicator.kt |
Removes superseded collapse restoration logic. |
android/src/main/java/com/swmansion/rnscreens/stack/header/config/StackHeaderInvalidationFlags.kt |
Simplifies invalidation flags. |
android/src/main/java/com/swmansion/rnscreens/stack/header/config/StackHeaderConfigurationProviding.kt |
Exposes update-batch state. |
android/src/main/java/com/swmansion/rnscreens/stack/header/config/StackHeaderConfigurationObserver.kt |
Splits invalidation and flush notifications. |
android/src/main/java/com/swmansion/rnscreens/stack/header/config/StackHeaderConfig.kt |
Delegates invalidation state to the coordinator. |
Review details
- Files reviewed: 14/14 changed files
- Comments generated: 0
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…e scrolling offset across fragment reattachments
7165a0c to
b46c6dc
Compare
Description
A stack v5 nested in tabs could permanently lose its header after a tab round trip: no app bar, and the content losing its collapsing scroll behavior, until some prop change happened to force a structural rebuild. The root cause was an ownership mistake in the header update flow — the invalidation flags ("what has not been applied yet") lived on the React-owned configuration, which outlives the native view that consumes them.
This PR moves the flags to the native consumer, unifies every update source (React props, color scheme, adoption, window attach) behind a single accumulate-then-flush path, and then — now that the flow can apply pure deltas — retains the header view itself across fragment view destruction, so a tab switch no longer rebuilds the header at all.
Along the way it fixes a handful of adjacent bugs that all came from the same place: the lift-on-scroll target lost on window reattach, the collapsed header re-expanding after any runtime configuration change, and a double rebuild on first mount when a color scheme override is set.
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1192.
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1756.
Details
Why the invalidation flags moved to the native implementation
invalidationFlagsused to live onStackHeaderConfig— the React-owned configuration object — and the coordinator cleared the bits it had applied. But that flag set is not a property of the configuration; it is consumer state: "what the current consumer has not applied yet." Storing consumer state on the producer only works while the two share a lifetime, and here they do not:StackHeaderConfigis React-owned and lives as long as the shadow node.StackHeaderCoordinatorLayoutis the fragment's view. Tabs drive the child fragment manager withdetach()/attach(), which caps children atCREATEDand destroys everyStackScreenFragmentview on every tab switch.So on reattach a brand new coordinator adopted a surviving configuration whose flags had already been consumed by the previous, now-dead coordinator — typically
NONE.needsRebuildwasfalseandappBarLayoutwasnull, so every branch ofprocessUpdatewas skipped, and nothing would ever raise the flags again. Only a prop change that raisedSTRUCTURE/SUBVIEWS(type,hidden,transparent,maxLines, a subview add/remove) resurrected the header.With
pendingFlagsowned by the coordinator, adoption is fully dirty by construction —invalidate(ALL)— because a freshly adopted configuration is, by definition, something this coordinator has applied nothing of.The path of updates
Everything now funnels through one path. The configuration observer protocol is two calls:
onInvalidated(flags)— accumulate only. Cheap, callable at any time, from any source.onFlushRequested()— the end of an update batch: apply what is pending now.and the coordinator has a single gate:
Every source is the same two lines — invalidate, then flush:
onInvalidated(flag)→didMountItems→onFlushRequested()invalidate(ALL)+ flushAppearance)applyUiNightMode→invalidate(STRUCTURE)+ flushinvalidate(LIFT_ON_SCROLL)+ flushinvalidate(...)— the flush gate handles the restThe last row is worth calling out: those config-side sites previously each carried their own
if (!isInsideMountTransaction) flushUpdates()guard, because the configuration was the one deciding when to apply. They are now plain invalidations.A concrete improvement from the unification: a
colorSchemeprop change arriving in the same commit as header prop changes used to produce a synchronous rebuild followed by a second update. It now coalesces into a singleprocessUpdate.Why
isUpdatePendingThe batching boundary is React's, and only the configuration knows about it — it is the one holding the
UIManagerListenerand trackingwillMountItems/didMountItems. Prop setters fire one at a time inside a mount transaction, so applying each invalidation as it arrives would mean N header updates (and potentially N rebuilds) per commit.Previously that knowledge was used in place: the configuration held the flags and decided when to flush. Once the decision to apply moves to the consumer, the consumer needs to ask the question the producer alone can answer — "is more coming?" — and that is
isUpdatePending.Deliberately a hint, not a command. As a read-only property it keeps mount-transaction knowledge inside
StackHeaderConfigwhile leaving the "can I apply right now?" decision in one place, alongside the coordinator's ownisAttachedToWindowcondition. It is also generic on purpose:StackHeaderConfigurationProvidingsays "more updates may still arrive in the current batch", and a non-React implementation is free to answer differently.Deferring the first flush until the view is attached
The header used to be built during coordinator construction (
init→ adoption), i.e. beforeonAttachedToWindowhad a chance to pin the color scheme. With acolorSchemeoverride that meant the first build ran under the un-pinned DayNight theme and was immediately thrown away and rebuilt.The
!isAttachedToWindowclause in the flush gate fixes that: an early flush only accumulates,onAttachedToWindowresolves the scheme first and then flushes once. Both first mount and reattach build the header exactly once, under the right theme. (A scheme change insidesetup()flushes by itself, and the trailing flush then no-ops.)onAttachedToWindowalso re-raisesLIFT_ON_SCROLLon every attach.AppBarLayout.onDetachedFromWindowcallsclearLiftOnScrollTargetView()and never re-resolves it.Preserving the collapsed state across updates
The color scheme work landed a narrow version of this: a rebuilt app bar starts expanded, so
applyUiNightModecapturedisAppBarFullyCollapsedand re-asserted it afterwards. With the color path no longer rebuilding synchronously, that capture/restore had to move intoprocessUpdate— and once there, it generalizes to every update for free.What this changes:
expandedTitleAppearance.fontSizeon a fully collapsedmedium/largeheader changes the CTL height (extraHeightForTitles), and nothing re-asserted the offset afterwards — the header jumped. New SFT step 16 intest-stack-header-title-appearance-android.maxLines == 1 && isAppBarFullyCollapsedsetExpandedblock inapplyTitleAndSubtitleis gone, along with theisAppBarFullyCollapsedparameter threaded into the applicator. Thectl.requestLayout()next to it stays —setExpandedrequests layout on the AppBarLayout, which does not mark the CTL child dirty, so the title/subtitle vertical split would never be recomputed.type,maxLines,transparent,collapsedTitleGravityMode, subview add/remove.applyScrollFlagsdeliberately snaps expanded, and the JS docs promised it. The restore now runs after it and wins, so a fully collapsed header survives a scroll-flag change (and expands when the new flags cannot collapse); a partially collapsed one still snaps expanded.StackHeaderConfig.android.types.tsis updated accordingly. This is a product decision, not a correctness one — the mechanics are safe either way.Two supporting details:
isAppBarFullyCollapsedis now cleared inremoveHeader()(the header is genuinely gone) rather thanresetHeader()(which also runs for a plain rebuild) — otherwise two rebuilds in the same frame, with no layout in between to re-populate the flag, would lose the collapsed state. And thehiddenearly-return inprocessUpdatenow just callsremoveHeader(), which is the same three calls it was making inline plus that clear.Fractional offsets still reset to expanded on a rebuild. Material exposes no public API for an arbitrary app bar offset; the closest bypasses the behavior's scrim and drag-callback updates and would need a post-layout hook, producing a visible expanded → fractional jump. Which is part of why the next section exists.
Why the retain approach
Even with all of the above, a tab round trip still destroyed and rebuilt the entire header: a visible recreation flash, and a collapse state that could only ever be restored to fully collapsed — a partial offset was always lost, since rebuild-and-restore has no way to express one.
But the fragment itself survives
detach()/attach(); only its view is destroyed. SoStackScreenFragmentnow caches itsStackHeaderCoordinatorLayoutand returns the same instance fromonCreateView, withtearDown()moved fromonDestroyViewtoonDestroy. TheFragmentManagerremoves the view from its container beforeonDestroyView, so the cached instance can be returned as-is. The pattern is already precedented in this repo —TabsScreenFragment.onCreateViewdoes the same.Retention preserves the exact offset, fractional included, because the offset lives in
ViewOffsetHelper.offsetTop, owned by the behavior in the app bar's layout params. With no saved state and no pending action,BaseBehavior.onLayoutChildsimply re-clamps and re-applies the current offset and re-dispatchesonOffsetChanged, lifted state included. Nothing has to be reconstructed.The update flow refactor is what makes retention nearly free — no new machinery:
!isAttachedToWindowgate means props changed while the tab is detached accumulate and apply as in-place deltas at reattach.appliedUiNightModesurvives with the view and dedupes the scheme re-check inonAttachedToWindow.LIFT_ON_SCROLLre-invalidation on attach covers exactly what Android clears on window detach.Two things that deliberately stay: the adoption
invalidate(ALL)(first mount is an adoption too, and rebuilds still happen inside a living coordinator on theme/type/hiddenchanges), and the toolbar menu controller lifecycle.Known issue, handled in a follow-up: a re-shown header comes back expanded
Retention surfaced an asymmetry in
hidden. Toggling it on and off while the tab is detached keeps the collapse (only the latest value is ever read, soremoveHeader()never runs), but hiding the header, looking at the screen, and then un-hiding it brings it back expanded —hiddeninvalidatesSTRUCTURE, so it takes the rebuild path throughremoveHeader(), which clears the collapse memory.This will be handled in a follow-up PR. Ticket with more details: https://github.com/software-mansion/react-native-screens-labs/issues/1781.
Changes
Native — update flow
StackHeaderConfigurationProviding: droppedinvalidationFlags/clearInvalidationFlags, addedisUpdatePending.StackHeaderConfigurationObserver:onConfigChanged(config)→onInvalidated(flags)+onFlushRequested().StackHeaderConfig: no longer stores or clears flags; prop setters invalidate through the observer,didMountItemsrequests a flush, and the per-siteisInsideMountTransactionflush guards are gone.StackHeaderCoordinatorLayout: ownspendingFlags; newinvalidate()/flushPendingUpdates()with a single gate (nothing pending / batch in progress / not attached to window);processUpdatesnapshots and clears the flags at entry and lost itsforcedFlagsparameter; adoption invalidatesALL;onAttachedToWindowresolves the color scheme, re-raisesLIFT_ON_SCROLLand flushes once.StackHeaderInvalidationFlags: removedAPPEARANCE,clearing()andisNotEmpty.Native — collapsed state
wasFullyCollapsedcapture/restore moved fromapplyUiNightModeintoprocessUpdate, applying to every update.isAppBarFullyCollapsedis cleared inremoveHeader()instead ofresetHeader(); thehiddenbranch ofprocessUpdatenow callsremoveHeader().StackHeaderApplicator.applyTitleAndSubtitlelost itsisAppBarFullyCollapsedparameter and thesetExpandedworkaround.Native — view retention
StackScreenFragmentcaches theStackHeaderCoordinatorLayoutand returns it fromonCreateView;tearDown()moved fromonDestroyViewtoonDestroy.JS / docs / tests
StackHeaderConfig.android.types.ts: thescrollFlag*remark now documents that a fully collapsed header is preserved (partial collapse still snaps expanded, and a header that can no longer collapse expands).tabs-stack-v5/test-stack-tabs-stack-in-tabs-header-persistence(Android only) — header, offset, menu state and delta-vs-rebuild changes across tab round trips, with and without acolorSchemeoverride.test-stack-header-title-appearance-android/scenario.md: new step 16 for the appearance-change-while-collapsed case.test-stack-subviews-android/scenario.md: corrected a known issue — a rebuild removes and re-adds only the app bar, so the content scroll position was never actually reset (what read as a reset was the header re-expanding and pushing content down), and a full collapse now survives the rebuild.Before & after - visual documentation
4599.mp4
Test plan
Run new
test-stack-tabs-stack-in-tabs-header-persistenceCIT. Note that step 19 (popping in the nested stack) is currently blocked, see: https://github.com/software-mansion/react-native-screens-labs/issues/1774.You can also use SFTs:
test-stack-header-title-appearance-android— new step 16, plus the existing appearance matrix.test-stack-subviews-android— subview add/remove while fully collapsed; scroll-flag switches (the behavior change from "always snaps expanded").test-stack-color-scheme— the scheme change while collapsed path, now going through the shared update path.test-stack-toolbar-menu-*— menu state across theme rebuilds and tab round trips.Checklist
Stack created with GitHub Stacks CLI • Give Feedback 💬