diff --git a/README.md b/README.md index ca31c3c..d17426a 100644 --- a/README.md +++ b/README.md @@ -757,37 +757,37 @@ Text( ) ``` -If there are multiple *States*, use the `.render()` method for the target *State*. +If there are multiple *States*, use the `.stateContent()` method for the target *State*. ```kt -viewStore.render { +viewStore.stateContent { Text( text = state.count.toString(), ) } ``` -When drawing the UI, if it does not match the target *State*, the `.render()` will not be executed. +When drawing the UI, if it does not match the target *State*, the `.stateContent()` will not be executed. Therefore, you can define components for each *State* side by side. ```kt -viewStore.render { +viewStore.stateContent { Text( text = "loading..", ) } -viewStore.render { +viewStore.stateContent { Text( text = state.count.toString(), ) } ``` -If you use lower components in the `render()` block, pass its instance. +If you use lower components in the `stateContent()` block, pass its instance. ```kt -viewStore.render { +viewStore.stateContent { YourComposable( viewStore = this, // ViewStore instance for CounterState.Main ) @@ -822,10 +822,10 @@ Button( ### Handling Events -Use ViewStore's `.handle()` with the target *Event*. +Use ViewStore's `.eventEffect()` with the target *Event*. ```kt -viewStore.handle { event -> +viewStore.eventEffect { event -> // do something.. } ``` @@ -833,7 +833,7 @@ viewStore.handle { event -> In the above example, you can also subscribe to the parent *Event* type. ```kt -viewStore.handle { event -> +viewStore.eventEffect { event -> when (event) { is CounterEvent.ShowToast -> // do something.. is CounterEvent.GoBack -> // do something.. diff --git a/doc/internal/adr/2026-04-30-viewstore-compose-naming.md b/doc/internal/adr/2026-04-30-viewstore-compose-naming.md index fcaa6d7..c3e7bb2 100644 --- a/doc/internal/adr/2026-04-30-viewstore-compose-naming.md +++ b/doc/internal/adr/2026-04-30-viewstore-compose-naming.md @@ -1,6 +1,6 @@ # `ViewStore.render` / `handle` の PascalCase 置き換えは採用しない -- 更新日: 2026-04-30 +- 更新日: 2026-05-25 ## 背景 @@ -29,3 +29,8 @@ - そのため今回は「Compose guideline への整合」より、「`ViewStore` DSL としての自然さ」を優先する。 - 既存 API には `@Suppress("ComposableNaming")` が必要だが、このコストは上記の不自然さを受け入れるより小さいと判断する。 - 将来、トップレベルでもメンバでもない、より自然な API 形が見つかった場合はあらためて検討してよい。 + +## 2026-05-25 追記 + +その後、API 名は `render` / `handle` から `stateContent` / `eventEffect` へリネームされた。 +この rename により、API の意味は Compose 文脈により沿うようになったが、member composable を PascalCase にするかという論点自体は変わっていない。 diff --git a/doc/internal/notes/2026-04-23-store-start-policy.md b/doc/internal/notes/2026-04-23-store-start-policy.md index 58caa0a..d4355a9 100644 --- a/doc/internal/notes/2026-04-23-store-start-policy.md +++ b/doc/internal/notes/2026-04-23-store-start-policy.md @@ -52,7 +52,7 @@ fun startPolicy(policy: StoreStartPolicy) - 未採用候補として `EAGER` も考えられる。これは「`dispatch()` や `state` の collect を待たず、Store 作成直後に start する」という意味になる。v1 では見送るのがよい。`attachObserver()` が start 前のみ許可という現行前提と衝突しやすい。 - `ON_FIRST_DISPATCH` では、`state` を collect しても start しない。その場合でも `StateFlow` としては current snapshot を読めるので、UI が「現在値の監視」と「副作用の開始」を分離しやすくなる。 - start 直後に `enter { event(...) }` のような one-shot event を出したい場合、現状の default では `state` の collect が先に start trigger になり、あとから `event` を購読した側が初回 event を見逃しうる。 -- この問題は Compose の `rememberViewStore()` でも起こりうる。`rememberViewStore()` は内部で `store.state.collectAsState()` を行う一方、event の collect は `ViewStore.handle()` 側の `LaunchedEffect` で始まるため、同じ画面内で両方を書いていても event collector が start 前に間に合う保証はない。 +- この問題は Compose の `rememberViewStore()` でも起こりうる。`rememberViewStore()` は内部で `store.state.collectAsState()` を行う一方、event の collect は `ViewStore.eventEffect()` 側の `LaunchedEffect` で始まるため、同じ画面内で両方を書いていても event collector が start 前に間に合う保証はない。 - この問題に対しては start policy が有効な回避策になりうる。`ON_FIRST_DISPATCH` なら `state` / `event` の購読を張ったあとに明示的な最初の `dispatch()` で start でき、`MANUAL` なら購読を張ったあとに `start()` を呼ぶ順序をさらに明示しやすい。 - ただし start policy が解決するのは「購読前に start しない」ことまでであり、`Store.event` 自体の replay semantics は変えない。start 後に購読した側へ過去 event を再配送するわけではない。 - `MANUAL` で start 前に `dispatch()` された場合は、暗黙 start や silently ignore ではなく、例外で失敗させる方がバグを早く見つけやすい。 diff --git a/koma-compose/src/commonMain/kotlin/io/github/komakt/koma/compose/ViewStore.kt b/koma-compose/src/commonMain/kotlin/io/github/komakt/koma/compose/ViewStore.kt index 3652597..ca709e6 100644 --- a/koma-compose/src/commonMain/kotlin/io/github/komakt/koma/compose/ViewStore.kt +++ b/koma-compose/src/commonMain/kotlin/io/github/komakt/koma/compose/ViewStore.kt @@ -59,17 +59,26 @@ class ViewStore internal constructor( * * Inside [block], this [ViewStore] is narrowed to [S2]. * - * @param block Composable function to render the narrowed state + * @param block Composable function to render content for the narrowed state */ @Suppress("ComposableNaming") @Composable - inline fun render(block: @Composable ViewStore.() -> Unit) { + inline fun stateContent(block: @Composable ViewStore.() -> Unit) { if (state is S2) { @Suppress("UNCHECKED_CAST") block(this as ViewStore) } } + @Deprecated( + message = "Use stateContent instead.", + replaceWith = ReplaceWith("stateContent(block)"), + level = DeprecationLevel.WARNING, + ) + @Suppress("ComposableNaming") + @Composable + inline fun render(block: @Composable ViewStore.() -> Unit) = stateContent(block) + /** * Collects only events of type [E2] while this composable is in the composition. * @@ -80,7 +89,7 @@ class ViewStore internal constructor( */ @Suppress("ComposableNaming") @Composable - inline fun handle(noinline block: ViewStore.(event: E2) -> Unit) { + inline fun eventEffect(noinline block: ViewStore.(event: E2) -> Unit) { val currentViewStore = rememberUpdatedState(this) val currentBlock = rememberUpdatedState(block) LaunchedEffect(eventFlow) { @@ -89,15 +98,24 @@ class ViewStore internal constructor( } } } + + @Deprecated( + message = "Use eventEffect instead.", + replaceWith = ReplaceWith("eventEffect(block)"), + level = DeprecationLevel.WARNING, + ) + @Suppress("ComposableNaming") + @Composable + inline fun handle(noinline block: ViewStore.(event: E2) -> Unit) = eventEffect(block) } /** * Remembers a [Store], collects its state as Compose state, and exposes it through a [ViewStore]. * * Collecting [Store.state] starts the Store immediately. - * Because [ViewStore.handle] starts collecting later from a [LaunchedEffect], startup events such - * as events emitted from an initial `enter {}` handler may be emitted before handlers in the same - * composition begin observing them. + * Because [ViewStore.eventEffect] starts collecting later from a [LaunchedEffect], startup events + * such as events emitted from an initial `enter {}` handler may be emitted before handlers in the + * same composition begin observing them. * * The [store] lambda is used only when a new remembered Store must be created for [key]. * For a given remembered Store instance, this function returns the same [ViewStore] instance across diff --git a/koma-compose/src/jvmTest/kotlin/io/github/komakt/koma/compose/ViewStoreJvmTest.kt b/koma-compose/src/jvmTest/kotlin/io/github/komakt/koma/compose/ViewStoreJvmTest.kt index c8d0652..df0ab16 100644 --- a/koma-compose/src/jvmTest/kotlin/io/github/komakt/koma/compose/ViewStoreJvmTest.kt +++ b/koma-compose/src/jvmTest/kotlin/io/github/komakt/koma/compose/ViewStoreJvmTest.kt @@ -32,18 +32,18 @@ class ViewStoreJvmTest { private val testDispatcher = UnconfinedTestDispatcher() @Test - fun render_callsBlockOnlyForMatchingState() = runTest(testDispatcher) { + fun stateContent_callsBlockOnlyForMatchingState() = runTest(testDispatcher) { val renderedValues = mutableListOf() withComposition( content = { ViewStore(state = UiState.Ready(10)) - .render { + .stateContent { renderedValues += state.value } ViewStore(state = UiState.Loading) - .render { + .stateContent { renderedValues += state.value } }, @@ -53,7 +53,7 @@ class ViewStoreJvmTest { } @Test - fun handle_collectsOnlySpecifiedEventType() = runTest(testDispatcher) { + fun eventEffect_collectsOnlySpecifiedEventType() = runTest(testDispatcher) { val events = MutableSharedFlow(extraBufferCapacity = 4) val handled = mutableListOf() @@ -62,7 +62,7 @@ class ViewStoreJvmTest { ViewStore( state = UiState.Ready(0), eventFlow = events, - ).handle { event -> + ).eventEffect { event -> handled += event } }, @@ -76,7 +76,7 @@ class ViewStoreJvmTest { } @Test - fun handle_usesLatestViewStoreAndLambdaAfterRecomposition() = runTest(testDispatcher) { + fun eventEffect_usesLatestViewStoreAndLambdaAfterRecomposition() = runTest(testDispatcher) { val events = MutableSharedFlow(extraBufferCapacity = 4) val handled = mutableListOf() var label = "initial" @@ -99,7 +99,7 @@ class ViewStoreJvmTest { ViewStore( state = viewState, eventFlow = events, - ).handle { event -> + ).eventEffect { event -> handled += "${(state as UiState.Ready).value}:$label:${event.value}" } } @@ -111,7 +111,7 @@ class ViewStoreJvmTest { ViewStore( state = viewState, eventFlow = events, - ).handle { event -> + ).eventEffect { event -> handled += "${(state as UiState.Ready).value}:$label:${event.value}" } } @@ -129,6 +129,46 @@ class ViewStoreJvmTest { assertEquals(listOf("2:updated:42"), handled) } + @Suppress("DEPRECATION") + @Test + fun render_delegatesToStateContent() = runTest(testDispatcher) { + val renderedValues = mutableListOf() + + withComposition( + content = { + ViewStore(state = UiState.Ready(7)) + .render { + renderedValues += state.value + } + }, + ) + + assertEquals(listOf(7), renderedValues) + } + + @Suppress("DEPRECATION") + @Test + fun handle_delegatesToEventEffect() = runTest(testDispatcher) { + val events = MutableSharedFlow(extraBufferCapacity = 4) + val handled = mutableListOf() + + withComposition( + content = { + ViewStore( + state = UiState.Ready(0), + eventFlow = events, + ).handle { event -> + handled += event + } + }, + afterSetContent = { + assertTrue(events.tryEmit(UiEvent.ValueChanged(100))) + }, + ) + + assertEquals(listOf(UiEvent.ValueChanged(100)), handled) + } + @Test fun rememberViewStore_providesCurrentStateAndDispatchesAction() = runTest(testDispatcher) { val store = TestStore(UiState.Ready(1))