Skip to content

feat: 页面随导航条目存活——TinyUIPage 与内置组件改用 Compose retain - #45

Merged
HarlonWang merged 2 commits into
mainfrom
feat/retain-page-host
Oct 28, 2026
Merged

HarlonWang merged 2 commits into
mainfrom
feat/retain-page-host

Conversation

@HarlonWang

@HarlonWang HarlonWang commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

注册流程所需框架能力的第三部分(#43、#44 已合;本 PR 合后发 0.14.0)。返回上一步时表单内容不能丢:页面实例跟着导航条目活,与原生页一致,不让业务把草稿塞 store 自救。

做法:Compose 官方 retain

androidx.compose.runtime:runtime-retain(为「在返回栈里、此刻没被组合的导航页」设计)。缺省存储离开组合即丢,宿主不做任何事时行为与以前完全一样;宿主给每个导航条目一个保留存储(Nav3 是一个十几行的 decorator,写法在 app-model §5),条目出栈 clearChild 时页面才关。框架不依赖任何导航库。

层 改动
TinyUIPage PageHost 由 retain 持有;离开组合 → visible(false)、摘掉 uriHandler / 剪贴板(期间答 E_UNSUPPORTED);存储放手(onRetired / onUnused)才 close()
内置组件 用户做出来的状态 retain:TextField 文本、CodeField 数字、Column / Row / LazyColumn 滚动位置;焦点、展开的下拉菜单仍 remember
UpdatesPage 当前包与已加载页面 retain:回来的是离开时那一页、打开时那个包;第一帧即组合出被保留的页面,否则存储会当它没被取回而放手
依赖 runtime-retain 1.12.1:composeMultiplatform 1.12.2 各端底下就是 androidx runtime 1.12.1,同代

选型对比过 ViewModel 作用域:宿主没有按条目的 ViewModelStore 时会落到 Activity 级、页面泄漏;retain 缺省即旧行为,且框架 API 不变。

验证

  • :compose:testAndroidHostTest 67、:updates:testAndroidHostTest 34、:sample:shared:testAndroidHostTest、apiCheck(API 无变化)
  • sample 四个 tab 各给一个保留存储:
    • Android 模拟器:form 页填邮箱 / 密码 / 勾选 / 验证码 → 切到 todos / hello → 切回,全部还在;todos 滚到中段并加载到第 2 页 → 切走再切回,截图逐像素一致
    • iOS 模拟器:form 页填邮箱 + 勾选 → 切走切回,都在
  • 第一次实测发现 TextField 文本丢了(JS 状态在、组件 remember 的文本没了),由此把组件状态也改成 retain

文档

  • app-model §5 重写:页面随导航条目存活、各层分工、Nav3 decorator 写法、栈深回收(宿主对最深条目 clearChild)、与页面预载的关系
  • roadmap:栈深回收更新、页面预载记推迟(首屏实测超预算再加)

🤖 Generated with Claude Code

Summary by Sourcery

Retain page instances and user-facing UI state with their navigation entries so returning to a page restores it as left.

New Features:

  • Keep page instances, user-entered component state, and scroll positions alive while their navigation entries remain retained.
  • Preserve the loaded package and page when returning to an Updates page.

Bug Fixes:

  • Prevent form input and list position from being lost when switching away from and back to a page.

Enhancements:

  • Adopt Compose retain storage for page and built-in component lifecycle management, while keeping default behavior unchanged without host-provided retention.
  • Document navigation-entry retention, host-managed back-stack cleanup, and the relationship to future page preloading.

Build:

  • Add the Compose runtime-retain dependency and expose it from the compose module.

Documentation:

  • Update the app model and roadmap to define retained page lifetimes, host integration, and back-stack reclamation.

PingPong 注册流程触发:返回上一步,填好的内容不能没了。

- TinyUIPage:PageHost 由 retain 持有,离开组合只算不可见(摘掉 uriHandler / 剪贴板),保留它的存储放手时才 close
- 内置组件:TextField 文本、CodeField 数字、Column / Row / LazyColumn 滚动位置 retain;焦点、下拉展开仍 remember
- UpdatesPage:当前包与已加载页面 retain,回来的是离开时那一页、打开时那个包
- 宿主不提供保留存储时是 Compose 缺省存储(离开即丢),行为与以前一致;提供方式见 app-model §5(Nav3 decorator 写法)
- 依赖 androidx.compose.runtime:runtime-retain 1.12.1(composeMultiplatform 1.12.2 底下的 runtime 同代)
- sample 四个 tab 各给一个存储:切走再切回,表单与滚动位置都在(Android / iOS 模拟器验过)
- 文档:app-model §5 重写;roadmap 记栈深回收与页面预载

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

This PR adopts Compose runtime-retain throughout page hosting, built-in components, update loading, and the sample navigation host so pages preserve engines and user-entered state while their navigation entries remain alive, while retaining the existing default behavior and deferring cleanup to host-controlled store clearing.

Sequence diagram for retained page navigation

sequenceDiagram
    participant Host as NavigationHost
    participant Store as RetainedValuesStore
    participant Page as TinyUIPage
    participant Engine as PageHost

    Host->>Store: LocalRetainedValuesStoreProvider(entry)
    Store->>Page: retain(PageHost)
    Page->>Engine: startOnce()
    Host->>Page: remove entry from composition
    Page->>Engine: visible(false)
    Page->>Engine: detach uriHandler and clipboard
    Host->>Page: restore entry
    Page->>Engine: visible(true)
    Host->>Store: clearChild(entry)
    Store->>Page: onRetired() or onUnused()
    Page->>Engine: close()
Loading

Flow diagram for retained built-in component state

flowchart TD
    Compose[Compose component]
    UserState{User-created state?}
    Retain[retain state]
    Remember[remember state]
    Restore[Restore when page returns]
    Reset[Reset when recomposed]

    Compose --> UserState
    UserState -->|text, code, scroll| Retain
    UserState -->|focus, open menu| Remember
    Retain --> Restore
    Remember --> Reset
Loading

File-Level Changes

Change Details Files
Introduces Compose retain as the lifecycle mechanism for page hosts, allowing navigation entries to preserve page engines and user state while off screen and closing them only when their retained store is cleared.
  • Adds the runtime-retain dependency and retains PageHost instances.
  • Separates leaving composition from page destruction: hides pages and removes URI/clipboard capabilities while retained, then closes on retirement or disposal.
  • Keeps page startup idempotent and documents host-provided retained-store behavior.
compose/build.gradle.kts
compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt
gradle/libs.versions.toml
Migrates built-in user-visible component state from ordinary composition memory to retained state.
  • Retains text and code-field values.
  • Retains vertical, horizontal, and lazy-list scroll positions.
  • Leaves transient focus and menu state as ordinary remember state.
compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt
Makes update-backed pages restore the exact bundle and loaded page instance associated with a retained navigation entry.
  • Retains the selected package and loaded-page result.
  • Loads the page through retained mutable state and composes it immediately when already available, preventing premature store release.
updates/build.gradle.kts
updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt
Adds retained-store provisioning to the sample host to model per-navigation-entry lifetime and validates tab switching behavior.
  • Creates a retained-values registry and scopes each tab by page key.
  • Documents and demonstrates preservation of form input and list position across tab changes.
sample/shared/build.gradle.kts
sample/shared/src/commonMain/kotlin/app/tinyui/sample/App.kt
Updates the application model and roadmap to define retained page ownership, host integration, and eventual stack-depth reclamation.
  • Documents the division of responsibility between TinyUIPage, components, UpdatesPage, and navigation hosts.
  • Adds a Navigation 3 decorator pattern that clears retained state when entries are popped.
  • Clarifies that only explicitly cleared deep-stack entries are remounted and defers page preloading.
docs/app-model.md
docs/roadmap.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 2a70789a-4435-4c35-89ea-4879eb5906fa
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

@codeseerbot codeseerbot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CodeSeer 审查

结论:批准(附 1 条建议)

摘要

本次改动引入 runtime-retain,使页面和内置组件状态可随宿主提供的导航条目存储存活,并在 sample 与 UpdatesPage 中接入了该存储。主要风险在于 TinyUIPage 将可变的页面配置作为 retain key:同一导航条目内发生重新挂载时,旧 PageHost 会被保留而非关闭。

整个 PR 至 b8cfb75 · gpt-5.6-terra

) {
val pageHost = remember(page, host, propsJson, sourceMaps) {
PageHost(page, host, propsJson, locals, sourceMaps = sourceMaps)
val retained = retain(page, host, propsJson, sourceMaps) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[建议] page、host、propsJson 或 sourceMaps 在同一导航条目内变化时,会生成新的 retain 值;旧的 RetainedPage 只是退出组合,仍被该条目的 store 持有,因此直到整个条目被 clearChild 前都不会触发 111/112 行的 close()。这在 UpdatesPage 的 E2/E6 回退中会发生:切换 bundle 后重新加载出的 page.module 会替换旧页面,失败页的引擎却继续占用资源。应为一次重新挂载显式释放旧的 retain child/PageHost,或约束并实现每个 retained navigation entry 内页面配置不可变,避免把重挂载产生的旧实例当作可返回页面保留。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

核实过 runtime-retain 1.12.1 源码,不成立:ManagedRetainedValuesStore.saveExitingValue 只在 isRetainingExitedValues(= isEnabled && !isContentComposed)时保留离开的值。条目在屏幕上(内容已组合)时,key 变化产生的旧值走 else 分支立即 onRetired → PageHost.close();只有整个条目离开组合期间离开的值才会被保留。条目重新进入组合时 onContentEnteredComposition 还会 purgeUnusedExitedValues,把没被取回的旧值关掉。所以 UpdatesPage 的 E2/E6 回退换包时,失败页的引擎当场关闭。

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 6 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt" line_range="50" />
<code_context>
 ) {
-    val pageHost = remember(page, host, propsJson, sourceMaps) {
-        PageHost(page, host, propsJson, locals, sourceMaps = sourceMaps)
+    val retained = retain(page, host, propsJson, sourceMaps) {
+        RetainedPage(PageHost(page, host, propsJson, locals, sourceMaps = sourceMaps))
     }
</code_context>
<issue_to_address>
**Hidden pages keep privileged work running**

When a retained page leaves composition while one of its JS timers or asynchronous callbacks is pending, `TinyUIPage` only sends `visible(false)` and leaves the retained `PageHost` scope alive; scheduled JS timers can still call `host.call` or make HTTP requests through the retained capability context, so a hidden page performs host actions after navigation.

Pause or cancel background callbacks and reject privileged host actions while the page is not visible, or explicitly document and enforce that retained pages may continue background work.

Also at `compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt:68-70`.
</issue_to_address>

### Comment 2
<location path="compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt" line_range="69-70" />
<code_context>
+        onDispose {
+            // off screen but maybe kept: hidden, and nothing to open links or copy with until it is back
+            pageHost.visible(false)
+            pageHost.context.uriHandler = null
+            pageHost.context.clipboard = null
+        }
     }
</code_context>
<issue_to_address>
**Off-screen calls use departed locals**

When a retained page leaves composition while its locals are page-scoped or become invalid and JS invokes a host capability before the page returns, the retained `PageHost` keeps those values in `PageContext.locals`, so its JS runtime passes disposed or stale objects to host capabilities and the call acts on stale state or fails.

Clear or replace `PageContext.locals` when the page leaves composition and refresh it when the page returns.

Also at `compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt:51`.
</issue_to_address>

### Comment 3
<location path="updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt" line_range="46-49" />
<code_context>
+    val loadedState = retain(bundle, name) { mutableStateOf<Result<LoadedPage>?>(null) }
+    LaunchedEffect(loadedState) {
+        if (loadedState.value != null) return@LaunchedEffect
+        loadedState.value = try {
             Result.success(bundle.page(name))
         } catch (e: CancellationException) {
</code_context>
<issue_to_address>
**Load failures persist on return**

When a transient package-file read failure occurs while loading a page that remains in a retained navigation entry, `Bundle.page(name)` failures are stored in `loadedState` as `Result.failure`; when the same retained entry returns, the non-null state skips loading again and keeps showing the error even if the underlying read problem has cleared.

Do not retain failed load results indefinitely; provide a retry/reset path or reload failures when the entry is revisited.
</issue_to_address>

### Comment 4
<location path="compose/build.gradle.kts" line_range="36" />
<code_context>
         commonMain.dependencies {
             api(libs.quickjs.kmp)
             implementation(compose.runtime)
+            implementation(libs.compose.runtime.retain)
             implementation(compose.foundation)
             implementation(libs.compose.material3)
</code_context>
<issue_to_address>
**Host integration cannot compile**

When an external host follows the documented retained-store integration without separately declaring `runtime-retain`, the documented Navigation 3 decorator calls `retainRetainedValuesStoreRegistry` and `LocalRetainedValuesStoreProvider`, but this `implementation` dependency is not exposed to consuming apps, so those imports are unresolved unless each host separately adds the artifact.

Expose `runtime-retain` as an `api` dependency, or explicitly document that host apps must declare it themselves.
</issue_to_address>

### Comment 5
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="236" />
<code_context>
     fun clip(t: String) = clipTo(t, maxLength)
-    var text by remember { mutableStateOf(clip(scope.get<String>("initialText") ?: "")) }
+    var text by retain { mutableStateOf(clip(scope.get<String>("initialText") ?: "")) }
     var focused by remember { mutableStateOf(false) }
     val focusRequester = remember { FocusRequester() }
     val focusManager = LocalFocusManager.current
</code_context>
<issue_to_address>
**State accumulates after nodes leave composition**

When a retained page repeatedly removes stateful components or scrolls through many stateful lazy-list rows before its entry is popped, `retain` keeps component state in the entry store after its composables leave composition, so removed nodes and visited rows accumulate stale state and increase memory use until the entry is popped.

Remove retained component state when its node leaves composition instead of retaining it for the entry's entire lifetime.

Also at `compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:195`, `compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:301`, `compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:420`.
</issue_to_address>

### Comment 6
<location path="compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt" line_range="420" />
<code_context>
 @Composable
 private fun LazyColumnComponent(scope: NodeScope) {
-    val state = rememberLazyListState()
+    val state = retain { LazyListState() }
     val node = scope.node
     scope.Commands { command ->
</code_context>
<issue_to_address>
**Returning to list triggers another page load**

When a `LazyColumn` has `onReachEnd`, is left while scrolled to the end, and is later composed again without its item count changing, `retain { LazyListState() }` restores the list at its previous end position, but `LazyColumnComponent` restarts its `LaunchedEffect` when composition returns and resets local `firedFor` to `-1`. The first snapshot therefore dispatches `onReachEnd` again even though the list length has not changed, causing an extra pagination request.

Keep the fired-for-end state across composition exits, or explicitly avoid treating the restored end position as a new arrival until the user scrolls away or the list grows.
</issue_to_address>

Sourcery assessment

Approval pending. 6 findings to address first.

Blocking findings: compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt:50, compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt:70, updates/src/commonMain/kotlin/app/tinyui/updates/UpdatesPage.kt:49, compose/build.gradle.kts:36, compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt:236, and 1 more


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread compose/build.gradle.kts Resolved
- LazyColumn:onReachEnd 已为哪个长度触发过随列表状态保留,停在底部切走再回来不再多拉一页
- UpdatesPage:只保留加载成功的页面,失败的回来时重试
- tinyui-compose 以 api 依赖带出 runtime-retain,宿主照文档写 decorator 不用另加
- app-model §5:被保留的页面照常在后台运行(计时器、http、host.call 不停,页面看 pageVisible());locals 应与导航条目同寿

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@codeseerbot codeseerbot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CodeSeer 审查

结论:批准(附 1 条建议)

上轮意见:1 条,已处理 0 条,待处理 1 条

  • compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt:50 [建议] page、host、propsJson 或 sourceMaps 在同一导航条目内变化时,会生成新的 retain 值;旧的 …

摘要

本次增量将 runtime-retain 以 api 暴露给宿主,并让 LazyColumn 的到尾触发状态随页面保留。UpdatesPage 对加载失败的保留结果改为在页面重新组合后重试,同时补充了相应的生命周期文档说明。基于给出的增量代码,未发现可确认的逻辑、资源或安全问题。

增量 b8cfb75..60ef5ca · gpt-5.6-terra

@HarlonWang
HarlonWang merged commit 71fe3bc into main Oct 28, 2026
5 checks passed
@HarlonWang
HarlonWang deleted the feat/retain-page-host branch October 28, 2026 10:41
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.

1 participant