Skip to content

feat(compose): generic switchable trace for lazy layout measure - #121

Open
bytemain wants to merge 192 commits into
mainfrom
cc/lazy-layout-trace
Open

feat(compose): generic switchable trace for lazy layout measure#121
bytemain wants to merge 192 commits into
mainfrom
cc/lazy-layout-trace

Conversation

@bytemain

@bytemain bytemain commented Aug 5, 2026

Copy link
Copy Markdown
Member

Diagnostics seam inside the lazy list measure pass, for task Tencent-TDS#990 (Thread IME shrink white screen).

Review boundary — read this instead of the main diff. GitHub compares against main, which carries a large unrelated staging2 divergence. The actual change is three files between c7a02127 (the commit the mobile carrier pins) and b751f14e:

compose/.../diagnostics/LazyLayoutTrace.kt      +210   new
compose/.../foundation/lazy/LazyList.kt          +16/-1
compose/.../foundation/lazy/LazyListMeasure.kt   +57/-1

git diff c7a02127 b751f14e -- compose/src/commonMain/kotlin/com/tencent/kuikly/compose/

Why in the library

A host reading LazyListState.layoutInfo sees only a snapshot after the pass. In the incident under investigation every consumer-visible counter reads identically between a fully white frame and a healthy one — same descendant counts, same draw count, same visible range. Which measure or placement first diverged is only answerable from inside the measure pass.

Generic by construction

No product vocabulary enters the library. The host supplies opaque traceSession / cycle / layoutGeneration / targetToken; they are compared for equality so records can be joined, and never parsed, persisted, or interpreted. The sink is injected by the caller and released with its composition, so this never becomes a second owner of any state.

Off means off

ENABLED is a compile-time constant. measure { } is inline with the check ahead of the lambda, so a disabled build gathers no geometry, traverses no list, and formats no string.

This branch sets it true because it is the forensics carrier; main keeps it false, and that constant is the one line a build flips. An earlier revision of this branch left it false and would have installed, run, and recorded nothing — a package that compiles green and traces nothing is the failure this work exists to expose, so it is called out rather than buried.

Fails closed rather than looking like data

With tracing enabled, a missing frame identity raises LazyTraceWiringError instead of substituting (-1, -1). A placeholder frame yields records that cannot be joined but still resemble data, which is worse than an absent record.

Coverage is unioned, not summed

lazyTraceViewportCoverage unions clipped spans. Summing would double-count sticky or overlapping items and report a viewport as fuller than it is — concealing the empty band the measurement exists to find.

Not included

LazyTraceStage declares Placement and NativeCommit; only MeasureResult is emitted. There are no library tests: this module has no test source set (compose/src has no commonTest, and build.gradle.kts declares no test dependencies). Adding that infrastructure changes the fork's build and affects upstream absorption audits, so it is left to the repo owner. Carrier-side behaviour tests exercising this seam are being added on the mobile side.

Merge status

Consumed by a forensics gitlink only — the mobile carrier pins this branch commit directly so a diagnostic package can be built without waiting on staging2. Per the repo rule, a publishable build must point at a staging2 commit; this SHA must not enter a release artifact.

bytemain and others added 30 commits June 18, 2026 22:12
V2 styled string nodes are rendered by ArkUI Text internally. Registering the legacy foreground-draw callback for those nodes can route them through OH_Drawing_TypographyPaint before the paragraph is layout-ready, which spams paragraph-is-not-formatted warnings.

Track the styled-string path on KRRichTextView, unregister/skip foreground draw for that path, and reset styled-string state when falling back to the legacy typography path.
Throttle Compose↔Native scroll sync (calc/expand deferral, contentSize
dedup, scrollEnd finalize), quantize ArkUI onScroll in fling, and draw
RichText when typography is ready during main-thread tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
Quantize ArkUI scroll callbacks and SubcomposeLayout deltas at 0.5px,
dedupe content size updates, and flush the final offset on scroll stop.
Add OHOS demo cold-start page params and a CanScrollForward repro page.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ess test

Add a 500-item repeated demo list with Card UI and inspector enabled to reproduce and profile HarmonyOS scroll performance locally.

Co-authored-by: Cursor <cursoragent@cursor.com>
Clamp ArkUI scroll-frame-begin offset at nested list edges and skip
compose-side expand/offset correction when nestedScroll is configured.

Co-authored-by: Cursor <cursoragent@cursor.com>
Only recalculate native content size when contentView main-axis height
changes during fling, avoiding ~650 redundant calc calls per gesture
while keeping near-bottom and scrollEnd sync unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
Extend KuiklyScrollTrace with remeasure, frame timing, and offset/drag
skip metrics for OHOS scroll stress validation; gate redundant
contentOffset and isDragging writes; skip resetViewVisible when unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
bytemain and others added 29 commits July 31, 2026 08:31
Signed-off-by: Jony <raft-mobile-jony@mail.build>
Co-authored-by: Jony <raft-mobile-jony@mail.build>
Signed-off-by: Android-Developer-4 <raft-mobile-android-developer-4@mail.build>
Co-authored-by: Android-Developer-4 <raft-mobile-android-developer-4@mail.build>
…#96)

Add SelectableText, a read-only plain text component rendered by each
platform's native text view so the OS selection experience is available
anchored to the selection. Baseline guarantee: word selection, drag
handles, Select all, Copy; further menu actions (Translate / Look Up /
Share / Android PROCESS_TEXT targets) appear only as provided by the OS
version, locale and installed services. The surface never opens an IME
and text mutates only via the text prop.

- core: SelectableTextView (KRSelectableTextView type) measured with the
  shared rich-text shadow, SelectableTextAttr reusing TextConst wire keys,
  ViewContainer.SelectableText DSL
- Android: KRSelectableTextView (TextView + setTextIsSelectable), system
  ActionMode; registered in KuiklyRenderViewBaseDelegator
- iOS: KRSelectableTextView (UITextView editable=NO selectable=YES),
  system edit menu; resolved by class-name convention
- OHOS: KRSelectableTextView (ARKUI_NODE_TEXT + NODE_TEXT_COPY_OPTION
  local-device copy scope), native selection menu; registered in
  ComponentsRegisterEntry
- Compose: SelectableText(text, modifier, style) with a pure
  style-to-props resolver; every supported field always resolves to a
  concrete wire value (deterministic defaults for unspecified fields) so
  style updates on the reusable node reset prior values on both the
  native renderer and the measuring shadow
- tests: Robolectric behavior contract (selectable/read-only/wire props)
  and commonTest teeth covering resolver defaults, density scaling,
  weight/align mapping, full wire coverage and the
  specified->Default reset sequence
- docs: docs/API/components/selectable-text.md with explicit capability
  boundary (baseline Select all/Copy; extras are platform-provided)

Raft: #Kuiklybase task #69 (Phase 1 of #proj-chat task Tencent-TDS#924)

Signed-off-by: BiSheng <raft-mobile-bisheng@mail.build>
Co-authored-by: BiSheng <raft-mobile-bisheng@mail.build>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Android-Developer-4 <raft-mobile-android-developer-4@mail.build>
Co-authored-by: Android-Developer-4 <raft-mobile-android-developer-4@mail.build>
Signed-off-by: Argus <argus@mail.build>
Co-authored-by: Argus <argus@mail.build>
Two independent defects starved the system selection experience on the
first real Android consumer (task #69 runtime reopen):

Chain B - native touch starvation: under a compose-hosted root, any hit
path containing Modifier.nativeDispatchCapture() (overlay/scrim barriers)
made the render root withhold all MotionEvents from native children, so
the selectable TextView never received the gesture. Native dispatch
resolution is now a three-state, branch-scoped policy (INHERIT / CAPTURE
/ RELEASE): the ancestor stance flows top-down per branch before sibling
reduction, each root-to-leaf path resolves to its deepest non-INHERIT
stance, and sibling branches combine with any-capture-wins. A RELEASE
region therefore only neutralizes capture ancestors on its own path -
including under shared hit-path prefixes, where a capture ancestor keeps
capturing for every branch that does not release it itself. RELEASE is
expressed through an internal marker node (no public ABI change:
PointerInputModifierNode keeps only the existing captureNativeDispatch
contract, and the policy enum is internal). SelectableText carries the
internal nativeDispatchRelease modifier so its region keeps native
delivery while barriers still block click-through everywhere else. The
root latch stays gesture-sticky at ACTION_DOWN (existing KRView
behavior).

Chain A - a11y mask: the compose semantics bridge derives an
accessibilityInfo clickable/long-clickable mask from compose click
semantics, which this surface does not have, so the delegate reported
the view as not long-clickable (breaking automation readouts and a11y
ACTION_LONG_CLICK) and the iOS setter would overwrite the text view's
native traits. Both platform views decline exactly this mask as a view
capability, and the Android view corrects the final node info from its
real flags in onInitializeAccessibilityNodeInfo, advertising
ACTION_LONG_CLICK and ACTION_SET_SELECTION; every other a11y prop
(role, testTag, plain text, state description) still applies.

Teeth: policy-tree commonTest executing the production
resolveNativeDispatchPolicyTree traversal (shared with HitPathTracker,
not mirrored) driven by the real capture/release
modifier nodes over branch trees (deep release over own ancestor,
shared-capture-ancestor fan-out keeps capturing for non-release
branches, sibling independence, symmetric deep capture, release-alone
no-capture, same-node dominance); KRViewSuperTouchDispatchTest wiring
the real superTouch root to a real native child via a non-invasive touch
listener - capture withholds the entire stream (fails on the pre-fix
framework), the decision is ACTION_DOWN-sticky, and release delivers the
full stream with system word selection starting; a11y teeth cover both
prop orders, assert truthful node info with selection actions, and
certify the delegate-owned props (viewIdResourceName, mask-decline) by
driving the real production delegate directly because Robolectric's
createAccessibilityNodeInfo does not route through attached delegates -
real uiautomator testTag/long-clickable readouts remain in the fresh
Alpha blind-test contract (dispatch/state certification only - real
ActionMode stays a device gate).

Raft: #Kuiklybase task #69 (runtime reopen; consumer task Tencent-TDS#924)

Signed-off-by: BiSheng <raft-mobile-bisheng@mail.build>
Co-authored-by: BiSheng <raft-mobile-bisheng@mail.build>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…n model, not -inset.top (#102)

OHOS realizes contentInset with an ArkUI margin, so the held refresh resting offset must clamp to zero rather than the iOS-style negative inset. This removes the deterministic 2x Activity refresh gap while preserving normal drag overscroll and release recovery.

Reviewed-exact: 64a0fc6
Task: #110

Signed-off-by: PaoDing <raft-mobile-paoding@mail.build>
Signed-off-by: HanXin <hanxin@mail.build>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Reviewed-exact: d2cb832

Signed-off-by: Codex-Kuikly-KMP <raft-mobile-codex-kuikly-kmp@mail.build>
Signed-off-by: Ark <raft-mobile-ark@mail.build>
Signed-off-by: Jony <jony@mail.build>
Co-authored-by: Jony <jony@mail.build>
Wake a retained Kuikly subcompose slot's draw ownership when it is successfully restored from the reusable pool, allowing hidden native descendants to flush visible on the first reactivated frame.

Cover same-key and compatible-key reuse, consecutive reuse cycles, and the already-active no-op path through the real precompose/dispose/retrieve state machine.

Signed-off-by: MingQi <raft-mobile-mingqi@mail.build>
Wake draw ancestry when a precomposed slot becomes active through real subcompose consumption.

Signed-off-by: MingQi <raft-mobile-mingqi@mail.build>
Dirty restored lazy descendants and force their draw ancestry after offscreen visibility is restored. Cover the real native mount, placement, restore, and clean-leaf lifecycle with production-callsite mutation teeth.

Signed-off-by: MingQi <raft-mobile-mingqi@mail.build>
Preserve child content for bare draw nodes, route view-aware fallback through ordinary draw overrides, and retain explicit view-aware precedence.

Add behavior coverage for all three implementer classes and a self-testing production-wiring constraint for LayoutNodeDrawScope.drawDirect.

Signed-off-by: MingQi <raft-mobile-mingqi@mail.build>
Signed-off-by: BiSheng <raft-mobile-bisheng@mail.build>
Co-authored-by: BiSheng <raft-mobile-bisheng@mail.build>
Task Tencent-TDS#183: synchronize Compose offset after native viewport changes.

Reviewed exact: 175c8ac
Independent review: task Tencent-TDS#205 FINAL FULL-SEAT PASS / 0 blocker.
Hosted exact matrix: 30852685305.

Signed-off-by: MingQi <mingqi@mail.build>
Signed-off-by: Android-Developer-1 <raft-mobile-android-developer-1@mail.build>
Co-authored-by: Android-Developer-1 <raft-mobile-android-developer-1@mail.build>
Signed-off-by: Jony <jony@mail.build>
Co-authored-by: Jony <jony@mail.build>
Signed-off-by: KMP-专家 <raft-mobile-kmp-expert@mail.build>
Co-authored-by: KMP-专家 <raft-mobile-kmp-expert@mail.build>
Keep the active programmatic scroll target aligned with the native scroller when a viewport shrink commits a smaller frame. Preserve owner fencing across synchronous native echoes, lock the behavior with a renderer-level regression fixture, and remove the obsolete source-code assertion gate.

Signed-off-by: MingQi <mingqi@mail.build>
Adds a diagnostics seam inside the lazy list measure pass, for a host that
needs to know which measure or placement first diverged from what the user
saw. A consumer reading LazyListState.layoutInfo only sees a snapshot after
the fact and cannot answer that.

Generic by construction: no product vocabulary enters the library. The host
supplies opaque traceSession/cycle/layoutGeneration/targetToken values, which
are compared for equality so records can be joined and never parsed,
persisted, or interpreted. The sink is injected by the caller and released
with its composition, so this never becomes a second owner of anything.

Switched by a compile-time constant rather than a runtime flag, so a disabled
build can be shown to do no work rather than merely to stay quiet: the
measure lambda is inline and the check precedes it, so no geometry is
gathered, no list is traversed and no string is built when tracing is off.
requireConsistentWith fails a build whose host and library switches disagree,
since that yields a chain silently missing one layer.

Viewport coverage unions clipped item spans instead of summing them, so
sticky or overlapping items cannot report a viewport as fuller than it is and
hide the empty band the measurement exists to expose.

measureLazyList takes the handle as a defaulted trailing parameter, so its
existing caller is unchanged and ordinary builds pass null.

Signed-off-by: CC-希乐 <raft-mobile-cc-xile@mail.build>
Without this the seam added in the previous commit could never fire: the sole
caller of measureLazyList passed nothing, so the defaulted parameter stayed
null and no record was ever produced.

The handle now reaches the measure policy through a static composition local,
read once where the policy is remembered rather than inside the measure lambda
— so the measure path performs no lookup, and a host that provides nothing
still pays nothing. Scoped to the provider's composition, so the handle is
released with the screen that supplied it instead of persisting as global
state.

Signed-off-by: CC-希乐 <raft-mobile-cc-xile@mail.build>
Two wiring gaps @mingqi found by reading the commit rather than the
description, either of which produces a package that compiles green and
traces nothing.

ENABLED was hardcoded false with no build wiring, so the inline guard
returned before the lambda and no record was ever produced — the exact
"looks healthy, contains nothing" failure this work exists to expose. This
forensics branch sets it true; main keeps it false, and that constant is the
single line a build flips.

The frame identity fell back to (-1, -1) when unsupplied, which yields
records that cannot be joined but still look like data. With tracing enabled
a missing frame is now a wiring error, and LazyList supplies a real identity:
a monotonic sequence and clock reading, allocated only when a host actually
provided a trace.

Signed-off-by: CC-希乐 <raft-mobile-cc-xile@mail.build>
Found by @mingqi. The remembered measure lambda did not list the trace handle
among its keys, so it closed over the first one permanently. The handle
carries the host's correlation identity — which changes per IME cycle and
layout generation — so every later record would be attributed to a stale
cycle: records that look valid and join to the wrong pass, which is worse
than no records.

Signed-off-by: CC-希乐 <raft-mobile-cc-xile@mail.build>
Measure records where items should go; this records that placement actually
ran for that frame, with the target's placed offset and size and the resulting
viewport coverage.

Without it the interval between "measure geometry was correct" and "the pixels
were white" is a blind spot crossable only by assuming adjacent timestamps
belong to the same frame — an inference already retracted once here. Placement
carries the same frame identity as the measure record.

Signed-off-by: CC-希乐 <raft-mobile-cc-xile@mail.build>
@mingqi read the exact and found the record sat at the top of the layout
block, before any it.place() ran — it merely re-reported measure's
positionedItems and proved nothing about placement. It now emits after the
item and header placement calls and after attachToScope, so it describes work
that has actually happened.

Signed-off-by: CC-希乐 <raft-mobile-cc-xile@mail.build>
On a viewport shrink this path corrects only the Compose offset; nothing in
it re-drives the native scroller. Whether the landed replay fix could apply
at all depends on a pending programmatic offset existing at that moment, and
that is not decidable by reading — the value is written on only two paths,
each with an early return.

Records the shrink direction, both offsets, content size, the pending
programmatic offset and the dirty flag, plus what the Compose offset was
corrected to and what the native offset was left at. Behind the same
compile-time switch, so ordinary builds are unaffected.

Signed-off-by: CC-希乐 <raft-mobile-cc-xile@mail.build>
Completes the shrink diagnostic per @mingqi. Recording only the shrink moment
would leave a null pending offset ambiguous between never installed and
already consumed by an earlier native echo — the same silence-versus-negative
trap this investigation has hit repeatedly.

Adds the install point in applyOffsetDelta (whose early return means an
unchanged offset installs nothing), the consume point in
consumeIgnoredScrollOffset (which clears whether or not it matched), and both
outcomes of #117's replay guard, so a skipped replay is visible rather than
silent. All behind the same compile-time switch.

Signed-off-by: CC-希乐 <raft-mobile-cc-xile@mail.build>
@bytemain
bytemain force-pushed the cc/lazy-layout-trace branch from d9f0cfa to 7271a4d Compare August 5, 2026 18:15
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