Skip to content

fix: show loading overlay immediately for remote config sub-screens#5694

Merged
jamesarich merged 1 commit into
mainfrom
jamesarich/fix-remote-node-blank-screen
Jun 1, 2026
Merged

fix: show loading overlay immediately for remote config sub-screens#5694
jamesarich merged 1 commit into
mainfrom
jamesarich/fix-remote-node-blank-screen

Conversation

@jamesarich

Copy link
Copy Markdown
Collaborator

Problem

When navigating to config/module sub-screens (Channels, LoRa, Device config, etc.) for remote nodes, users see a blank screen instead of a loading indicator. This doesn't affect locally-connected Bluetooth nodes.

Root Cause

Navigation 3's rememberViewModelStoreNavEntryDecorator creates a separate ViewModelStore per navigation entry. When navigating from SettingsRoute.Settings(destNum=X) → any sub-screen (e.g., SettingsRoute.ChannelConfig), a brand new RadioConfigViewModel is created with:

  • responseState = Empty (no loading overlay visible)
  • channelList = emptyList() (no content to render)

The LaunchedEffect(Unit) that sets responseState = Loading and fires admin requests only runs after the first composition frame — creating a blank screen on that frame.

For local nodes, this is imperceptible because the init block subscribes to local database flows that emit data immediately. For remote nodes, data must be fetched over the mesh network, so the blank state persists visibly until the LaunchedEffect fires.

Fix

Three targeted changes:

1. RadioConfigViewModel.ensureLoadingForRemote()

New method that sets responseState = Loading() when the node is remote and state is still Empty. Called synchronously during composition (before collectAsStateWithLifecycle reads the StateFlow), so the initial Compose State value already reflects Loading.

2. configComposable() consolidation

Updated to:

  • Accept routeInfo parameter
  • Call ensureLoadingForRemote() via remember{} before content renders
  • Include the LaunchedEffect(setResponseStateLoading) that was previously duplicated in every content lambda

3. LoRaConfigScreen empty-state handling

Previously returned early on empty channelList, rendering nothing (not even a scaffold). Now renders a RadioConfigScreenList with the loading overlay visible while data loads.

Why the Settings menu is unaffected

The parent SettingsRoute.Settings entry uses its own entry in settingsGraph (not configComposable), so ensureLoadingForRemote() is never called for it. The Settings menu continues to start with responseState = Empty as before.

Testing

  • Added 3 new unit tests for ensureLoadingForRemote():
    • Sets loading state for remote nodes ✓
    • No-op for local nodes ✓
    • No-op when already loading ✓
  • All existing tests pass
  • spotlessApply, detekt, assembleDebug, test, allTests all pass

When navigating to config/module sub-screens for remote nodes,
Navigation 3's per-entry ViewModelStore creates a new RadioConfigViewModel
with empty state (responseState=Empty). The LaunchedEffect that sets
Loading and fires admin requests only runs after the first composition
frame, resulting in a blank screen flash.

This commit fixes the issue with three targeted changes:

1. Add RadioConfigViewModel.ensureLoadingForRemote() which sets
   responseState=Loading when the node is remote and state is still
   Empty. This is called synchronously before the content composable
   reads the StateFlow.

2. Update configComposable() to call ensureLoadingForRemote() via
   remember{} before rendering content, and consolidate the
   LaunchedEffect(setResponseStateLoading) into configComposable
   instead of duplicating it in each content lambda.

3. Fix LoRaConfigScreen's early return on empty channelList to
   render a RadioConfigScreenList with the loading overlay instead
   of returning nothing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added the bugfix PR tag label May 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📄 Docs staleness check — advisory

This PR modifies user-facing UI source files but does not update any page under docs/en/user/ or docs/en/developer/.

⚠️ Doc changes propagate to 3 consumers: in-app docs browser, Jekyll site (GitHub Pages), and meshtastic.org (Docusaurus sync). Updating a page in docs/en/ automatically flows to all three.

Changed source files:

feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt

What to check:

Changed area Likely doc page
feature/messaging/ docs/en/user/messages-and-channels.md
feature/node/ docs/en/user/nodes.md or docs/en/user/node-metrics.md
feature/map/ docs/en/user/map-and-waypoints.md
feature/connections/ docs/en/user/connections.md
feature/settings/ docs/en/user/settings-radio-user.md or docs/en/user/settings-module-admin.md
feature/firmware/ docs/en/user/firmware.md
feature/intro/ docs/en/user/onboarding.md
feature/discovery/ docs/en/user/discovery.md
feature/docs/ Internal docs infrastructure
core/ui/ docs/en/developer/codebase.md or component-specific user pages

New page checklist (if adding a new doc page):

  1. Create the .md file in docs/en/user/ or docs/en/developer/ with last_updated frontmatter
  2. Register in DocBundleLoader.kt with string resources (in-app browser)
  3. Jekyll and Docusaurus sync pick up new pages automatically — no config change needed

If this PR does not require a doc update (e.g., internal refactor, bug fix, test change), add the skip-docs-check label to dismiss this check.

Cross-platform note: This check is advisory while doc coverage matures. Both Android and Apple repos use the same skip-docs-check label and advisory severity. See meshtastic/design standards for shared conventions.

@github-actions

Copy link
Copy Markdown
Contributor

🖼️ Preview staleness check — advisory

This PR modifies UI composables but does not update any *Previews.kt files.

Previews power screenshot tests and in-app docs screenshots. Keeping them current ensures visual regression coverage stays accurate.

Changed UI files:

feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt

What to check:

Pattern Preview file convention
feature/{name}/…/ui/ or component/ feature/{name}/…/*Previews.kt
core/ui/…/ core/ui/…/ (previews colocated)

Adding previews checklist:

  1. Create or update a *Previews.kt file in the same module with @PreviewLightDark
  2. Add @Suppress("PreviewPublic") if the preview is consumed by screenshot-tests
  3. Add corresponding @PreviewTest function in screenshot-tests/src/screenshotTest/
  4. Run ./gradlew :screenshot-tests:updateDebugScreenshotTest to generate reference images

If this PR does not require preview updates (e.g., logic-only change, non-visual refactor), add the skip-preview-check label to dismiss.

@codecov

codecov Bot commented May 31, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
2531 1 2530 0
View the top 1 failed test(s) by shortest run time
org.meshtastic.core.model.NodeTest::isOnline_usesStrictThresholdBoundary
Stack Traces | 0.129s run time
java.lang.AssertionError: Expected value to be true.
	at org.junit.Assert.fail(Assert.java:89)
	at kotlin.test.junit.JUnitAsserter.fail(JUnitSupport.kt:56)
	at kotlin.test.Asserter.assertTrue(Assertions.kt:766)
	at kotlin.test.junit.JUnitAsserter.assertTrue(JUnitSupport.kt:30)
	at kotlin.test.Asserter.assertTrue(Assertions.kt:776)
	at kotlin.test.junit.JUnitAsserter.assertTrue(JUnitSupport.kt:30)
	at kotlin.test.AssertionsKt__AssertionsKt.assertTrue(Assertions.kt:44)
	at kotlin.test.AssertionsKt.assertTrue(Unknown Source)
	at kotlin.test.AssertionsKt__AssertionsKt.assertTrue$default(Assertions.kt:42)
	at kotlin.test.AssertionsKt.assertTrue$default(Unknown Source)
	at org.meshtastic.core.model.NodeTest.isOnline_usesStrictThresholdBoundary(NodeTest.kt:37)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
	at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
	at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
	at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
	at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
	at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
	at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
	at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175)
	at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84)
	at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47)
	at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65)
	at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77)
	at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28)
	at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19)
	at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
	at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88)
	at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103)
	at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63)
	at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
	at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122)
	at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72)
	at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
	at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@jamesarich jamesarich added this pull request to the merge queue Jun 1, 2026
Merged via the queue into main with commit baa66e6 Jun 1, 2026
18 checks passed
@jamesarich jamesarich deleted the jamesarich/fix-remote-node-blank-screen branch June 1, 2026 12:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix PR tag

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant