Skip to content

refactor(Android, Stack v5): move invalidation flags to native implementation - #4599

Open
kligarski wants to merge 3 commits into
mainfrom
@kligarski/stack-v5-android-update-flow-refactor
Open

refactor(Android, Stack v5): move invalidation flags to native implementation#4599
kligarski wants to merge 3 commits into
mainfrom
@kligarski/stack-v5-android-update-flow-refactor

Conversation

@kligarski

@kligarski kligarski commented Sep 4, 2026

Copy link
Copy Markdown
Member

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

invalidationFlags used to live on StackHeaderConfig — 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:

  • StackHeaderConfig is React-owned and lives as long as the shadow node.
  • StackHeaderCoordinatorLayout is the fragment's view. Tabs drive the child fragment manager with detach()/attach(), which caps children at CREATED and destroys every StackScreenFragment view 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. needsRebuild was false and appBarLayout was null, so every branch of processUpdate was skipped, and nothing would ever raise the flags again. Only a prop change that raised STRUCTURE/SUBVIEWS (type, hidden, transparent, maxLines, a subview add/remove) resurrected the header.

With pendingFlags owned by the coordinator, adoption is fully dirty by constructioninvalidate(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:

private fun flushPendingUpdates() {
    val provider = currentProvider ?: return
    if (pendingFlags.isEmpty) return
    if (provider.isUpdatePending) return   // more updates coming in this batch
    if (!isAttachedToWindow) return        // theme not resolved yet; accumulate
    processUpdate(provider)
}

Every source is the same two lines — invalidate, then flush:

Source Path
React prop change setter → onInvalidated(flag)didMountItemsonFlushRequested()
Adoption (first mount and every reattach) invalidate(ALL) + flush
Color scheme change (prop, system config, or RN Appearance) applyUiNightModeinvalidate(STRUCTURE) + flush
Window attach invalidate(LIFT_ON_SCROLL) + flush
Async prop icon resolution, content scroll view change plain invalidate(...) — the flush gate handles the rest

The 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 colorScheme prop 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 single processUpdate.

Why isUpdatePending

The batching boundary is React's, and only the configuration knows about it — it is the one holding the UIManagerListener and tracking willMountItems/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 StackHeaderConfig while leaving the "can I apply right now?" decision in one place, alongside the coordinator's own isAttachedToWindow condition. It is also generic on purpose: StackHeaderConfigurationProviding says "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. before onAttachedToWindow had a chance to pin the color scheme. With a colorScheme override that meant the first build ran under the un-pinned DayNight theme and was immediately thrown away and rebuilt.

The !isAttachedToWindow clause in the flush gate fixes that: an early flush only accumulates, onAttachedToWindow resolves the scheme first and then flushes once. Both first mount and reattach build the header exactly once, under the right theme. (A scheme change inside setup() flushes by itself, and the trailing flush then no-ops.)

onAttachedToWindow also re-raises LIFT_ON_SCROLL on every attach. AppBarLayout.onDetachedFromWindow calls clearLiftOnScrollTargetView() 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 applyUiNightMode captured isAppBarFullyCollapsed and re-asserted it afterwards. With the color path no longer rebuilding synchronously, that capture/restore had to move into processUpdate — and once there, it generalizes to every update for free.

What this changes:

  • Fixes an unhandled bug. Changing expandedTitleAppearance.fontSize on a fully collapsed medium/large header changes the CTL height (extraHeightForTitles), and nothing re-asserted the offset afterwards — the header jumped. New SFT step 16 in test-stack-header-title-appearance-android.
  • Subsumes the subtitle workaround. The maxLines == 1 && isAppBarFullyCollapsed setExpanded block in applyTitleAndSubtitle is gone, along with the isAppBarFullyCollapsed parameter threaded into the applicator. The ctl.requestLayout() next to it stays — setExpanded requests layout on the AppBarLayout, which does not mark the CTL child dirty, so the title/subtitle vertical split would never be recomputed.
  • Covers every rebuild trigger, not just the theme one: type, maxLines, transparent, collapsedTitleGravityMode, subview add/remove.
  • Changes documented behavior for scroll flags. applyScrollFlags deliberately 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.ts is updated accordingly. This is a product decision, not a correctness one — the mechanics are safe either way.

Two supporting details: isAppBarFullyCollapsed is now cleared in removeHeader() (the header is genuinely gone) rather than resetHeader() (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 the hidden early-return in processUpdate now just calls removeHeader(), 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. So StackScreenFragment now caches its StackHeaderCoordinatorLayout and returns the same instance from onCreateView, with tearDown() moved from onDestroyView to onDestroy. The FragmentManager removes the view from its container before onDestroyView, so the cached instance can be returned as-is. The pattern is already precedented in this repo — TabsScreenFragment.onCreateView does 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.onLayoutChild simply re-clamps and re-applies the current offset and re-dispatches onOffsetChanged, lifted state included. Nothing has to be reconstructed.

The update flow refactor is what makes retention nearly free — no new machinery:

  • The !isAttachedToWindow gate means props changed while the tab is detached accumulate and apply as in-place deltas at reattach.
  • appliedUiNightMode survives with the view and dedupes the scheme re-check in onAttachedToWindow.
  • The LIFT_ON_SCROLL re-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/hidden changes), 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, so removeHeader() never runs), but hiding the header, looking at the screen, and then un-hiding it brings it back expanded — hidden invalidates STRUCTURE, so it takes the rebuild path through removeHeader(), 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: dropped invalidationFlags / clearInvalidationFlags, added isUpdatePending.
  • StackHeaderConfigurationObserver: onConfigChanged(config)onInvalidated(flags) + onFlushRequested().
  • StackHeaderConfig: no longer stores or clears flags; prop setters invalidate through the observer, didMountItems requests a flush, and the per-site isInsideMountTransaction flush guards are gone.
  • StackHeaderCoordinatorLayout: owns pendingFlags; new invalidate() / flushPendingUpdates() with a single gate (nothing pending / batch in progress / not attached to window); processUpdate snapshots and clears the flags at entry and lost its forcedFlags parameter; adoption invalidates ALL; onAttachedToWindow resolves the color scheme, re-raises LIFT_ON_SCROLL and flushes once.
  • StackHeaderInvalidationFlags: removed APPEARANCE, clearing() and isNotEmpty.

Native — collapsed state

  • The wasFullyCollapsed capture/restore moved from applyUiNightMode into processUpdate, applying to every update.
  • isAppBarFullyCollapsed is cleared in removeHeader() instead of resetHeader(); the hidden branch of processUpdate now calls removeHeader().
  • StackHeaderApplicator.applyTitleAndSubtitle lost its isAppBarFullyCollapsed parameter and the setExpanded workaround.

Native — view retention

  • StackScreenFragment caches the StackHeaderCoordinatorLayout and returns it from onCreateView; tearDown() moved from onDestroyView to onDestroy.

JS / docs / tests

  • StackHeaderConfig.android.types.ts: the scrollFlag* remark now documents that a fully collapsed header is preserved (partial collapse still snaps expanded, and a header that can no longer collapse expands).
  • New CIT 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 a colorScheme override.
  • 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-persistence CIT. 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

  • Included code example that can be used to test this change.
  • For visual changes, included screenshots / GIFs / recordings documenting the change.
  • Ensured that CI passes

Stack created with GitHub Stacks CLIGive Feedback 💬

@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Team

Run ID: 6088a166-92e8-406c-b50a-e1c92de766b6

📥 Commits

Reviewing files that changed from the base of the PR and between 73591ca and b46c6dc.

📒 Files selected for processing (14)
  • android/src/main/java/com/swmansion/rnscreens/stack/header/StackHeaderApplicator.kt
  • android/src/main/java/com/swmansion/rnscreens/stack/header/StackHeaderCoordinatorLayout.kt
  • android/src/main/java/com/swmansion/rnscreens/stack/header/config/StackHeaderConfig.kt
  • android/src/main/java/com/swmansion/rnscreens/stack/header/config/StackHeaderConfigurationObserver.kt
  • android/src/main/java/com/swmansion/rnscreens/stack/header/config/StackHeaderConfigurationProviding.kt
  • android/src/main/java/com/swmansion/rnscreens/stack/header/config/StackHeaderInvalidationFlags.kt
  • android/src/main/java/com/swmansion/rnscreens/stack/screen/StackScreenFragment.kt
  • apps/src/tests/component-integration-tests/tabs-stack-v5/index.ts
  • apps/src/tests/component-integration-tests/tabs-stack-v5/test-stack-tabs-stack-in-tabs-header-persistence/index.tsx
  • apps/src/tests/component-integration-tests/tabs-stack-v5/test-stack-tabs-stack-in-tabs-header-persistence/scenario-description.ts
  • apps/src/tests/component-integration-tests/tabs-stack-v5/test-stack-tabs-stack-in-tabs-header-persistence/scenario.md
  • apps/src/tests/single-feature-tests/stack-v5/test-stack-header-title-appearance-android/scenario.md
  • apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android/scenario.md
  • src/components/stack/header/StackHeaderConfig.android.types.ts
💤 Files with no reviewable changes (1)
  • android/src/main/java/com/swmansion/rnscreens/stack/header/StackHeaderApplicator.kt

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The 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.

Changes

Stack header update flow

Layer / File(s) Summary
Invalidation contract and batching
android/src/main/java/com/swmansion/rnscreens/stack/header/config/*
Configuration updates now use observer invalidation callbacks and isUpdatePending. Mount transactions flush accumulated flags after completion.
Coordinator update and collapsed-state handling
android/src/main/java/com/swmansion/rnscreens/stack/header/StackHeaderCoordinatorLayout.kt, android/src/main/java/com/swmansion/rnscreens/stack/header/StackHeaderApplicator.kt
The coordinator owns pending flags, applies updates conditionally, and restores a fully collapsed header after rebuilds. Title updates no longer force expansion.
Fragment layout retention
android/src/main/java/com/swmansion/rnscreens/stack/screen/StackScreenFragment.kt
The fragment retains StackHeaderCoordinatorLayout across onDestroyView and tears it down in onDestroy.
Persistence scenarios and documentation
apps/src/tests/component-integration-tests/tabs-stack-v5/*, apps/src/tests/single-feature-tests/stack-v5/*, src/components/stack/header/StackHeaderConfig.android.types.ts
New Android scenarios cover tab round trips, offsets, menus, rebuilds, pushed screens, and color schemes. Existing behavior notes and scroll-flag documentation were updated.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to b46c6

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
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning 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:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: moving Android Stack v5 invalidation flags to the native implementation.
Description check ✅ Passed The description directly explains the invalidation-flow refactor, header retention, state-preservation fixes, tests, documentation changes, and known limitation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

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.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kligarski
kligarski force-pushed the @kligarski/stack-v5-android-update-flow-refactor branch from 1a384fd to 7165a0c Compare September 4, 2026 10:07
@kligarski
kligarski marked this pull request as ready for review September 4, 2026 10:08
@kligarski
kligarski requested a balanced review from Copilot September 4, 2026 10:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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.

Base automatically changed from @kligarski/stack-v5-android-preserve-toolbar-state to main September 4, 2026 11:55
@kligarski
kligarski force-pushed the @kligarski/stack-v5-android-update-flow-refactor branch from 7165a0c to b46c6dc Compare September 4, 2026 11:55
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.

2 participants