Skip to content

Fix and improvements to Flow<String>.asMarkdownState()#564

Merged
mikepenz merged 7 commits into
mikepenz:developfrom
MizzleDK:fix/as-markdown-state-improvements
Jun 7, 2026
Merged

Fix and improvements to Flow<String>.asMarkdownState()#564
mikepenz merged 7 commits into
mikepenz:developfrom
MizzleDK:fix/as-markdown-state-improvements

Conversation

@MizzleDK

@MizzleDK MizzleDK commented May 12, 2026

Copy link
Copy Markdown
Contributor

Description

After creating this issue, I looked into coming up with a proper solution.

I discovered two issues with Flow<String>.asMarkdownState():

  1. It emits a (hot) StateFlow on line 305 (emitAll(markdownState.state)). This flow never completes and means only the very first emission from the string flow will be parsed and emitted as a markdown state.
  2. It creates a new instance of MarkdownStateImpl on every emission from the string flow. This can cause the markdown component to blink when it goes from Loading -> Success, and it's wasteful when you can just create a single instance and reuse / update it.

Also added a demo page that allows users to update the flow and toggle retainState. Quick video below:

Screen_recording_20260512_223619.webm

Note that the parsed markdown flashes briefly when retainState is set to false. This is because of the rapid state changes (Loading -> Success). When retainState is set to true, it'll skip the Loading state and won't flash, as evident in the video.

Fixes #563

Type of change

  • Bug fix (non-breaking change which fixes an issue)

How Has This Been Tested?

I've verified that it works as expected via emulator testing alongside console logs on every state update.

Also added a set of unit tests. The first 4 work both before and after my changes. The fifth one, which tests continuous emissions, doesn't work prior to my changes, but works after my changes.

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

@MizzleDK MizzleDK changed the title Improvements to Flow<String>.asMarkdownState() Fix and improvements to Flow<String>.asMarkdownState() May 12, 2026
@MizzleDK MizzleDK mentioned this pull request May 12, 2026
3 tasks
@mikepenz mikepenz requested a review from Copilot May 13, 2026 15:24

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.

Pull request overview

Fixes Flow<String>.asMarkdownState() so a single underlying MarkdownStateImpl is reused across upstream emissions instead of being re-created (and the previous emitAll of a never-completing StateFlow no longer swallows subsequent updates). Also adds a sample page/viewmodel and unit tests around the new behavior.

Changes:

  • Rework Flow<String>.asMarkdownState() to share one MarkdownStateImpl, calling updateInput + parse per upstream value and switching the emitted state via flatMapLatest.
  • Add AsMarkdownStateTest (and kotlinx-coroutines-test dependency) covering Loading/Success emissions, empty/complex content, and incremental updates.
  • Add a FlowMarkdownPage + FlowMarkdownViewModel sample (with a new Flow icon and top-bar entry) demonstrating retainState toggling and auto-updating content.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/model/MarkdownState.kt Rewrites asMarkdownState() to reuse a single MarkdownStateImpl via onEach + flatMapLatest.
multiplatform-markdown-renderer/src/commonTest/kotlin/com/mikepenz/markdown/model/AsMarkdownStateTest.kt New tests covering loading/success behavior and incremental updates.
multiplatform-markdown-renderer/build.gradle.kts Adds hard-coded kotlinx-coroutines-test:1.10.2 test dependency.
sample/shared/src/commonMain/kotlin/com/mikepenz/markdown/sample/FlowMarkdownViewModel.kt New sample VM driving a MutableStateFlow<String> through asMarkdownState with retain/auto-update toggles.
sample/shared/src/commonMain/kotlin/com/mikepenz/markdown/sample/FlowMarkdownPage.kt Compose page rendering the VM state with switches/buttons and a Markdown view.
sample/shared/src/commonMain/kotlin/com/mikepenz/markdown/sample/App.kt Wires up a third showFlow page mode mutually exclusive with debug/licenses.
sample/shared/src/commonMain/kotlin/com/mikepenz/markdown/sample/TopAppBar.kt Adds a flowClick parameter and matching IconButton.
sample/shared/src/commonMain/kotlin/com/mikepenz/markdown/sample/icon/Flow.kt New three-wave Flow ImageVector icon.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +291 to 326
val markdownState = MarkdownStateImpl(
Input(
content = "",
lookupLinks = lookupLinks,
flavour = flavour,
parser = parser,
referenceLinkHandler = referenceLinkHandler,
retainState = retainState,
)
)
var isFirst = true
return transform {
if (isFirst || !retainState) {
emit(State.Loading(referenceLinkHandler))
isFirst = false
}
val markdownState = MarkdownStateImpl(

return onEach { content ->
// Update the state with new content
markdownState.updateInput(
Input(
content = it,
content = content,
lookupLinks = lookupLinks,
flavour = flavour,
parser = parser,
referenceLinkHandler = referenceLinkHandler,
retainState = retainState,
)
)

markdownState.parse()
emitAll(markdownState.state)
}.flatMapLatest {
// Emit all state changes from the state flow
flow {
if (isFirst) {
emit(State.Loading(referenceLinkHandler))
isFirst = false
}
emitAll(markdownState.state)
}
}
Comment on lines 303 to 326
return onEach { content ->
// Update the state with new content
markdownState.updateInput(
Input(
content = it,
content = content,
lookupLinks = lookupLinks,
flavour = flavour,
parser = parser,
referenceLinkHandler = referenceLinkHandler,
retainState = retainState,
)
)

markdownState.parse()
emitAll(markdownState.state)
}.flatMapLatest {
// Emit all state changes from the state flow
flow {
if (isFirst) {
emit(State.Loading(referenceLinkHandler))
isFirst = false
}
emitAll(markdownState.state)
}
}
Comment on lines +63 to +77
fun setAutoUpdate(enabled: Boolean) {
_autoUpdate.value = enabled

if (enabled) {
autoUpdateJob = viewModelScope.launch {
while (true) {
delay(2000)
updateContent()
}
}
} else {
autoUpdateJob?.cancel()
autoUpdateJob = null
}
}

@OptIn(ExperimentalCoroutinesApi::class)
class FlowMarkdownViewModel {
private val viewModelScope = CoroutineScope(Dispatchers.Main)
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.advanceUntilIdle
Comment on lines +111 to +166
@Test
fun asMarkdownState_incrementalContentBuildup() = runBlocking {
// Given a flow that gradually builds up markdown content
val contentFlow = MutableStateFlow("# Title")
val states = mutableListOf<State>()

// Create a SINGLE asMarkdownState flow and collect from it continuously
val stateFlow = contentFlow.asMarkdownState(retainState = false)

// Start collecting in background
val job = launch {
stateFlow.collect { state ->
states.add(state)
}
}

// Wait for initial emissions (Loading + Success) - use real delay since parse uses Dispatchers.Default
delay(500)
assertTrue(states.size >= 2, "Should have at least Loading + Success for first emission, got ${states.size}: $states")
assertIs<State.Loading>(states[0], "First state should be Loading")
assertIs<State.Success>(states[1], "Second state should be Success")
assertEquals("# Title", (states[1] as State.Success).content)

val stateCountAfterFirst = states.size

// Update with more content - this tests that the flow continues to work
contentFlow.value = "# Title\n\nSome paragraph text."
delay(500)

// Verify we got NEW states (Loading + Success for the second emission)
assertTrue(states.size > stateCountAfterFirst, "Should have new states after second emission, got ${states.size} total states")
val newStates = states.subList(stateCountAfterFirst, states.size)
assertTrue(newStates.any { it is State.Loading }, "Should have Loading state for second update, got: $newStates")
assertTrue(newStates.any { it is State.Success && it.content.contains("paragraph text") },
"Should have Success state with new content, got: $newStates")

val stateCountAfterSecond = states.size

// Third update
contentFlow.value = "# Title\n\nSome paragraph text.\n\n- Item 1\n- Item 2"
delay(500)

// Verify we got ANOTHER set of new states
assertTrue(states.size > stateCountAfterSecond, "Should have new states after third emission, got ${states.size} total states")
val thirdUpdateStates = states.subList(stateCountAfterSecond, states.size)
assertTrue(thirdUpdateStates.any { it is State.Loading }, "Should have Loading state for third update, got: $thirdUpdateStates")
val finalSuccess = thirdUpdateStates.filterIsInstance<State.Success>().lastOrNull()
assertTrue(finalSuccess != null && finalSuccess.content.contains("- Item 2"),
"Should have Success state with list items, got: $thirdUpdateStates")

job.cancel()

// Verify the pattern: we should have multiple Loading states (one per update with retainState=false)
val loadingStates = states.filterIsInstance<State.Loading>()
assertTrue(loadingStates.size >= 3, "Should have at least 3 Loading states (one per emission), got ${loadingStates.size}: $states")
}
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
Comment on lines 271 to 290
/**
* Transforms a [Flow] of markdown content strings into a [Flow] of parsed [State] for use in non-composable contexts like view models.
* As soon as the flow is collected, it will start parsing the content, and emit the state once ready.
*
* @param lookupLinks Whether to lookup links in the parsed tree or not
* @param retainState Whether to retain the state of the [MarkdownState] or not, when the input changes
* @param flavour The [MarkdownFlavourDescriptor] to use for parsing.
* @param parser The [MarkdownParser] to use for parsing.
* @param referenceLinkHandler The [ReferenceLinkHandler] to use for storing links.
*
* @return A [Flow] of [State] that represents the parsed markdown state.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun Flow<String>.asMarkdownState(
lookupLinks: Boolean = true,
retainState: Boolean = false,
flavour: MarkdownFlavourDescriptor = GFMFlavourDescriptor(),
parser: MarkdownParser = MarkdownParser(flavour),
referenceLinkHandler: ReferenceLinkHandler = ReferenceLinkHandlerImpl(),
): Flow<State> {
Comment on lines +21 to +23
path(
fill = SolidColor(Color.Black)
) {
mikepenz and others added 4 commits June 7, 2026 14:17
- remove duplicated kotlinx-coroutines-test (now provided by shared catalog)

via com.mikepenz:version-catalog 0.16.0 -> 0.17.1:
- compileSdk 36 -> 37
- targetSdk 36 -> 37
- compose 1.11.1 -> 1.11.2
- compose-wear 1.6.1 -> 1.6.2
- compose-multiplatform 1.11.0 -> 1.11.1
- kotlin 2.3.21 -> 2.4.0
- screenshot 0.0.1-alpha14 -> 0.0.1-alpha15
- lintGradle 1.0.0-beta01 -> 1.0.0-rc01
- aboutLibraries 14.2.0 -> 15.0.0-b03
- composeHotReload 1.1.1 -> 1.2.0-alpha01
- stabilityAnalyzer 0.7.5 -> 0.9.0
- adds kotlinx-coroutines-test

API dump regenerated: Kotlin 2.4.0 prefixes synthetic Compose lambda
singletons with the group id (internal-only, no public API change).
@mikepenz mikepenz force-pushed the fix/as-markdown-state-improvements branch from b02af85 to bc41309 Compare June 7, 2026 12:30
- move Flow and Recomposition demos into SamplesPage tab row alongside
  Streaming and the file-based samples
- drop the debug/flow TopAppBar toggles and their now-unused icons
- add kotlinx-coroutines-swing to desktop so Dispatchers.Main resolves
  in the Flow demo

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.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.

mikepenz added 2 commits June 7, 2026 15:04
Replace the onEach/flatMapLatest/emitAll(state) plumbing with
transformLatest. The prior approach emitted Loading for second+ updates
only when the previous inner collector happened to observe the hot
StateFlow transition before flatMapLatest cancelled it, a timing race
under StateFlow conflation.

transformLatest now emits Loading only when updateInput transitioned to
it (i.e. not retaining), then emits parse() directly. Late subscribers
whose content already parsed see Success without a flash.

Also harden the sample FlowMarkdownViewModel:
- @Suppress("DEPRECATION") for the intentional deprecated API demo
- cancel any existing autoUpdate job before relaunching to avoid leaks
@mikepenz mikepenz merged commit 061bb9e into mikepenz:develop Jun 7, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Creating MarkdownState in viewmodel

3 participants