Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
97b75e1
docs(scatterlab): Android prebuilt 배포 파이프라인 설계
kdwkr Sep 10, 2026
1de4b8d
docs(scatterlab): Android prebuilt 구현 계획
kdwkr Sep 10, 2026
65603af
fix(android): 잘린 layout 밖으로 나간 text decoration offset을 clamp
kdwkr Sep 10, 2026
a3d740a
test(android): visibleTextEnd의 non-ellipsis 분기 커버리지를 추가
kdwkr Sep 10, 2026
de61bac
feat(scatterlab): 소비자 빌드가 fork의 Android 아티팩트를 쓰게 하는 settings 스크립트
kdwkr Sep 10, 2026
7f1d36b
fix(scatterlab): 다운로드 실패를 전부 fail()로 통일해 fail-closed 를 보장
kdwkr Sep 10, 2026
394f80c
ci(scatterlab): Android prebuilt 빌드·릴리스 워크플로
kdwkr Sep 10, 2026
237c92a
docs(scatterlab): 구현 계획의 `verify-tarball.sh` 인자와 커밋 trailer를 실제에 맞춘다
kdwkr Sep 10, 2026
b833e3e
fix(scatterlab): Android prebuilt 릴리스를 draft 게이트로 검증 전엔 안 보이게 한다
kdwkr Sep 10, 2026
d55917b
fix(scatterlab): verify가 draft 릴리스를 REST tags 엔드포인트로 못 찾던 문제를 고친다
kdwkr Sep 10, 2026
fb18918
fix(scatterlab): 최종 리뷰 3건 반영 - 계획 문서 상태·소비자 테스트 배선·심볼 게이트
kdwkr Sep 10, 2026
aec0368
docs(scatterlab): 출고 절차의 목표 버전을 0.87.1-scatterlab.4 로 올린다
kdwkr Sep 10, 2026
2350288
fix(scatterlab): CodeRabbit 리뷰 3건 반영 - 버전 불일치 fail-closed, draft 재업로드…
kdwkr Sep 10, 2026
7ba0bad
fix(scatterlab): cold-cache 테스트를 환경 미비 시 skip 하게 하고, 계획 문서의 배선/핀 순서를 고친다
kdwkr Sep 10, 2026
4cf6e54
fix(scatterlab): android prebuilt 감사 지적 7건을 반영한다
kdwkr Sep 10, 2026
9adc707
docs(scatterlab): android-prebuilt 문서를 최근 라운드의 코드 변경에 맞춘다
kdwkr Sep 10, 2026
393ebd1
fix(scatterlab-prebuild-android): 재실행 시 draft 재생성 전 남은 태그를 조건부로 정리한다
kdwkr Sep 10, 2026
79e8eef
fix(android-prebuilt): 캐시 삭제 경쟁과 무한 대기 가능한 curl 다운로드를 고친다
kdwkr Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/scatterlab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,10 @@ controlled는 키스트로크마다 JS를 왕복해 `-_setAttributedString:`을

검증 절차와 회귀 판정 기준은 [ime-qa.md](ime-qa.md).

## Android prebuilt

Android는 fork의 npm tarball에 든 `ReactAndroid/**` 소스를 아예 컴파일하지 않는다 — RNGP가 `com.facebook.react:react-android:<VERSION_NAME>`을 Maven Central에서 force resolve 하기 때문이다. 패치된 AAR을 실어 보내는 경로는 [`android-prebuilt.md`](android-prebuilt.md)에 있다.

## iOS prebuilt core를 우리가 만든다

0.86에서 iOS prebuilt는 **opt-out 기본값**이고, prebuilt가 켜지면 `podspec_sources`가 헤더만 반환해 **모든 React\* pod의 구현이 `React.xcframework`에서 온다**(81개 podspec 중 69개가 이 스위치를 탄다). 실측: `RCTUITextView` / `RCTUITextField` / `RCTTextInputComponentView` 세 클래스 모두 상류 0.86.2 아티팩트의 Mach-O에 심볼로 존재한다(`nm -gU React`). 즉 **iOS 소스 수정은 prebuilt가 켜진 채로는 조용히 무효**다.
Expand Down
347 changes: 347 additions & 0 deletions .github/scatterlab/__tests__/android-prebuilt-consumer-test.sh

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions .github/scatterlab/allowed-tarball-diff.txt
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,14 @@ Libraries/Components/ScrollView/ScrollViewStickyHeader.js
# (Simeji and other third-party IMEs) treat that as the document moving under them and
# drop the composition. See README.md.
ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTAttributedTextUtils.mm

# Android text decoration crash: drawSpannedDecoration() passed offsets from the full
# Spanned into a numberOfLines-truncated Layout, so getPrimaryHorizontal() threw
# IndexOutOfBoundsException while drawing an underline/strikethrough that the truncation
# had hidden. Upstream https://github.com/react/react-native/pull/58366 (open).
# Reaches the app only through this fork's Android prebuilt release - see android-prebuilt.md.
ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt

# Points the consumer's Gradle build at this fork's Android artifacts. Added file, so the
# gate sees it as a difference from upstream. See android-prebuilt.md.
scripts/android/scatterlab-prebuilt-maven.gradle
158 changes: 158 additions & 0 deletions .github/scatterlab/android-prebuilt.md

Large diffs are not rendered by default.

1,069 changes: 1,069 additions & 0 deletions .github/scatterlab/plans/2026-09-10-android-prebuilt.md

Large diffs are not rendered by default.

381 changes: 381 additions & 0 deletions .github/workflows/scatterlab-prebuild-android.yml

Large diffs are not rendered by default.

23 changes: 19 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CLAUDE.md

`scatterlab/react-native` — `react/react-native`의 fork. 설계 문서는 [`.github/scatterlab/README.md`](.github/scatterlab/README.md), IME 검증 절차는 [`.github/scatterlab/ime-qa.md`](.github/scatterlab/ime-qa.md). 이 파일은 **여기서 작업할 때의 운용 규칙과 함정**만 담는다.
`scatterlab/react-native` — `react/react-native`의 fork. 설계 문서는 [`.github/scatterlab/README.md`](.github/scatterlab/README.md), Android 아티팩트 배포는 [`.github/scatterlab/android-prebuilt.md`](.github/scatterlab/android-prebuilt.md), IME 검증 절차는 [`.github/scatterlab/ime-qa.md`](.github/scatterlab/ime-qa.md). 이 파일은 **여기서 작업할 때의 운용 규칙과 함정**만 담는다.

## 이 레포의 목적

Expand All @@ -10,7 +10,7 @@ zeta가 **자체 수정한 RN을 출고할 수 있는 경로**를 갖기 위한
- 작업 브랜치: **`scatterlab/0.87.1`** (기본 브랜치, 태그 `v0.87.1`에서 분기)
- 지난 라인: `scatterlab/0.86.2`, `scatterlab/0.87.0` — 남겨두지만 새 작업은 올리지 않는다
- 계측 브랜치: `scatterlab/0.86.2-ime-probe` — `SLIME` 로그가 붙은 실험용. 0.86.2 라인에 묶여 있고 **배포 대상 아님**
- 산출물: npm `@scatterlab/react-native@0.87.1-scatterlab.N` + GitHub Release `prebuilt-ios-<version>`
- 산출물: npm `@scatterlab/react-native@0.87.1-scatterlab.N` + GitHub Release `prebuilt-ios-<version>` + `prebuilt-android-<version>`(모든 fork 버전에 필수)
- 소비: npm alias — `"react-native": "npm:@scatterlab/react-native@<version>"`

`main`은 상류 동기화용으로만 둔다. **`main`에 push하지 않는다** — 상류 워크플로가 발화한다.
Expand All @@ -23,7 +23,7 @@ zeta가 **자체 수정한 RN을 출고할 수 있는 경로**를 갖기 위한
| `@react-native/*` sibling 7개 exact 핀 변경 | `@react-native/codegen@<fork버전>`을 npm에서 찾다 install 실패. 8개 패키지를 다 배포해야 함 |
| `scripts/releases/set-version.js` / `set-rn-artifacts-version.js` 실행 | 전자는 sibling 범위를 전부 재작성, 후자는 `VERSION_NAME`을 재작성. 둘 다 위 두 항을 정확히 깨뜨린다 |
| `package.json`의 `bin` 변경 | zeta의 codepush 배포가 `node_modules/.bin/react-native` 심링크를 복사한다 |
| `v*` 태그 생성 | 상류 `publish-npm.yml`의 글롭 `v0.*.*`가 `v0.87.1-무엇이든`도 매치하고, 그 워크플로의 `set_hermes_versions` 잡은 repo 게이트가 없다. 태그는 `prebuilt-ios-` / `sl-` 처럼 `v`로 시작하지 않게 |
| `v*` 태그 생성 | 상류 `publish-npm.yml`의 글롭 `v0.*.*`가 `v0.87.1-무엇이든`도 매치하고, 그 워크플로의 `set_hermes_versions` 잡은 repo 게이트가 없다. 태그는 `prebuilt-ios-` / `prebuilt-android-` / `sl-` 처럼 `v`로 시작하지 않게 |
| 버전 접미사에 대시 2개 | `-scatterlab.N` 고정. fork 접미사 제거 정규식이 greedy하다 |
| 릴리스 에셋 clobber | warm `~/Library/Caches/ReactNative`를 가진 개발자가 낡은 xcframework를 영구히 쓴다. 새 `-scatterlab.N`을 낸다 |

Expand All @@ -39,7 +39,22 @@ gh workflow run scatterlab-publish.yml --repo scatterlab/react-native --ref scat
- 인증은 **npm Trusted Publishing (OIDC)** — 토큰 없음. `permissions: id-token: write` + `node-version: 24`(핀 제거 금지, `ubuntu-latest` 기본 npm 10.9.8은 요구치 11.5.1 미달)
- npm은 **workflow_ref 클레임을 최상위 워크플로 파일명과 대조**하고 패키지당 trusted publisher는 1개다. `npm publish`를 재사용/컴포짓 워크플로로 옮기면 인증이 깨진다
- **게이트**: tarball이 상류 동일 base 버전과 `allowed-tarball-diff.txt` 밖에서 다르면 실패. 소스를 새로 건드리면 그 파일에 경로를 추가해야 한다
- publish 직후 **~1분간 install이 `ETARGET`으로 실패**한다(packument와 dist-tags 캐시가 별개). 버전 bump PR은 install 확인 후에
- publish 직후 **~1분간 install이 `ETARGET`으로 실패**한다(packument와 dist-tags 캐시가 별개). **소비자(zeta) 쪽 핀 bump PR**은 install 확인 후에 — fork 자체의 `version` 필드 bump는 이것과 다른 얘기로, 이 워크플로를 돌리기 **전에** 이미 머지돼 있어야 한다(아래 "Android prebuilt" 참조)

## Android prebuilt

Android는 `com.facebook.react:react-android:<VERSION_NAME>`을 Maven Central에서 force resolve 하므로 **fork의 `ReactAndroid/**` 수정은 npm으로 안 간다**. 패치된 AAR을 따로 낸다.

```bash
gh workflow run scatterlab-prebuild-android.yml --repo scatterlab/react-native --ref scatterlab/0.87.1 \
-f version=0.87.1-scatterlab.N -f verify_symbol=<이번에 추가한 식별자> -f dry_run=false
```

`--ref`로 체크아웃하는 브랜치의 `packages/react-native/package.json` `version`이 **이미** `-f version=`과 같아야 한다 — 워크플로의 `prepare`가 이 둘을 대조하고, 실제 릴리스(`dry_run=false`)에서 다르면 하드 실패한다. 즉 fork의 버전 bump 커밋은 이 명령을 돌리기 전에 먼저 머지돼 있어야 한다.

**순서 제약**: prebuilt 릴리스가 npm보다 먼저 있어야 한다. 없으면 소비자 Gradle configure가 abort한다. 설계·함정은 [`.github/scatterlab/android-prebuilt.md`](.github/scatterlab/android-prebuilt.md).

`verify_symbol`이 load-bearing이다. 모든 fork 버전의 AAR 좌표가 동일해서, 이 검사 없이는 "아티팩트가 있다"가 "패치가 들어 있다"를 전혀 보증하지 않는다.

## iOS prebuilt

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,26 @@ private fun drawDecorationLine(
}
}

/**
* The last offset [layout] can resolve a horizontal position for. Truncation (via `numberOfLines`)
* can leave the last line shorter than [Layout.getLineEnd] reports, since [layout] keeps the full
* untruncated text and only clips how much of it is laid out. When the last line ends in a tail
* ellipsis, anything from the ellipsis onward is unresolvable too; a leading/middle ellipsis
* doesn't shorten what's resolvable on that line, so it's left to [Layout.getLineEnd].
*/
private fun visibleTextEnd(layout: Layout): Int {
val lastLine = layout.lineCount - 1
val lineStart = layout.getLineStart(lastLine)
val lineEnd = layout.getLineEnd(lastLine)
val ellipsisStart = layout.getEllipsisStart(lastLine)
val ellipsisCount = layout.getEllipsisCount(lastLine)
return if (ellipsisCount > 0 && ellipsisStart + ellipsisCount == lineEnd - lineStart) {
lineStart + ellipsisStart
} else {
lineEnd
}
}

/**
* Shared decoration drawing entry point used by [ReactUnderlineSpan] and [ReactStrikethroughSpan].
* Computes a density-aware stroke thickness, sets up a dedicated paint (to avoid mutating the
Expand Down Expand Up @@ -174,13 +194,19 @@ internal fun drawSpannedDecoration(
}
}

val startLine = layout.getLineForOffset(start)
val endLine = layout.getLineForOffset(end)
val visibleEnd = visibleTextEnd(layout)
val clampedStart = min(start, visibleEnd)
val clampedEnd = min(end, visibleEnd)

val startLine = layout.getLineForOffset(clampedStart)
val endLine = layout.getLineForOffset(clampedEnd)
for (line in startLine..endLine) {
val baseline = layout.getLineBaseline(line).toFloat()
val rawX1 =
if (line == startLine) layout.getPrimaryHorizontal(start) else layout.getLineLeft(line)
val rawX2 = if (line == endLine) layout.getPrimaryHorizontal(end) else layout.getLineRight(line)
if (line == startLine) layout.getPrimaryHorizontal(clampedStart)
else layout.getLineLeft(line)
val rawX2 =
if (line == endLine) layout.getPrimaryHorizontal(clampedEnd) else layout.getLineRight(line)
// Normalize for RTL text where start may be to the right of end.
val x1 = min(rawX1, rawX2)
val x2 = max(rawX1, rawX2)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,21 @@

package com.facebook.react.views.text

import android.graphics.Canvas
import android.graphics.Color
import android.text.StaticLayout
import android.text.TextPaint
import android.text.TextUtils
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class TextDecorationStyleTest {
@Test
fun fromStringSolid() {
Expand Down Expand Up @@ -50,4 +62,124 @@ class TextDecorationStyleTest {
fun fromStringEmptyDefaultsToSolid() {
assertThat(TextDecorationStyle.fromString("")).isEqualTo(TextDecorationStyle.SOLID)
}

@Test
fun drawSpannedDecorationClampsSpanPastTailEllipsis() {
val layout = buildTailEllipsizedLayout()
val visibleEnd = layout.getLineStart(0) + layout.getEllipsisStart(0)
val baseline = layout.getLineBaseline(0).toFloat()
val x1 = layout.getPrimaryHorizontal(0)
val x2 = layout.getPrimaryHorizontal(visibleEnd)
val canvas = mock<Canvas>()

// The span covers the whole string, well past what survived the ellipsis; pre-fix this
// called layout.getPrimaryHorizontal(TAIL_TEXT.length) and crashed with
// IndexOutOfBoundsException, since the ellipsized layout only resolves up to visibleEnd.
drawSpannedDecoration(
0,
TAIL_TEXT.length,
canvas,
layout,
Color.BLACK,
TextDecorationStyle.SOLID,
) { _, lineBaseline, thickness ->
lineBaseline + thickness + 1f
}

verify(canvas).drawLine(eq(x1), eq(baseline + 1f), eq(x2), eq(baseline + 1f), any())
}

@Test
fun drawSpannedDecorationSkipsSpanEntirelyPastTailEllipsis() {
val layout = buildTailEllipsizedLayout()
val visibleEnd = layout.getLineStart(0) + layout.getEllipsisStart(0)
val baseline = layout.getLineBaseline(0).toFloat()
val x = layout.getPrimaryHorizontal(visibleEnd)
val canvas = mock<Canvas>()

// The whole span (e.g. a nested Text) starts after the ellipsis, fully hidden: it must
// collapse to a zero-length line at the visible boundary, not draw anything past it.
drawSpannedDecoration(
visibleEnd,
TAIL_TEXT.length,
canvas,
layout,
Color.BLACK,
TextDecorationStyle.SOLID,
) { _, lineBaseline, thickness ->
lineBaseline + thickness + 1f
}

verify(canvas).drawLine(eq(x), eq(baseline + 1f), eq(x), eq(baseline + 1f), any())
}

@Test
fun drawSpannedDecorationDoesNotClampWhenMaxLinesTruncatesWithoutEllipsis() {
val layout = buildClippedLayout()
val baseline = layout.getLineBaseline(0).toFloat()
val x1 = layout.getPrimaryHorizontal(0)
val x2 = layout.getPrimaryHorizontal(CLIP_TEXT.length)
val canvas = mock<Canvas>()

// ellipsizeMode="clip" (no TruncateAt at all) exercises visibleTextEnd's non-ellipsis
// branch, which falls back to Layout.getLineEnd(). Unlike the tail-ellipsis cases above,
// Android's StaticLayout only shortens getLineEnd() in response to maxLines when an
// ellipsis is set (see TextLayoutManager.calculateHeight's "StaticLayout only seems to
// change its height in response to maxLines when ellipsizing" comment) - setMaxLines(1)
// alone still resolves the whole string on the one line it caps the layout to. So this
// branch is always a no-op clamp in practice: this pins that the span keeps drawing to the
// real end of the text instead of being incorrectly cut short.
drawSpannedDecoration(
0,
CLIP_TEXT.length,
canvas,
layout,
Color.BLACK,
TextDecorationStyle.SOLID,
) { _, lineBaseline, thickness ->
lineBaseline + thickness + 1f
}

verify(canvas).drawLine(eq(x1), eq(baseline + 1f), eq(x2), eq(baseline + 1f), any())
}

/**
* A single line, tail-ellipsized right after "Hello" because the paragraph break in
* [TAIL_TEXT] hides everything after it once `maxLines` is reached.
*/
private fun buildTailEllipsizedLayout(): StaticLayout {
val paint = TextPaint().apply { textSize = 32f }
val layout =
StaticLayout.Builder.obtain(TAIL_TEXT, 0, TAIL_TEXT.length, paint, 400)
.setMaxLines(1)
.setEllipsize(TextUtils.TruncateAt.END)
.build()
assertThat(layout.lineCount).isEqualTo(1)
assertThat(layout.getEllipsisCount(0)).isGreaterThan(0)
return layout
}

/**
* A single line capped by `maxLines` with no ellipsis at all. [CLIP_TEXT] is soft-wrapped
* (no hard line break) at a width that would need multiple lines, so unlike
* [buildTailEllipsizedLayout]'s hard-broken [TAIL_TEXT], `maxLines` here actually keeps
* `lineCount` at 1 - but `getLineEnd(0)` still equals the full string, not a truncated
* offset, since only ellipsizing shortens it.
*/
private fun buildClippedLayout(): StaticLayout {
val paint = TextPaint().apply { textSize = 32f }
val layout =
StaticLayout.Builder.obtain(CLIP_TEXT, 0, CLIP_TEXT.length, paint, 100)
.setMaxLines(1)
.build()
assertThat(layout.lineCount).isEqualTo(1)
assertThat(layout.getEllipsisCount(0)).isEqualTo(0)
assertThat(layout.getLineEnd(0)).isEqualTo(CLIP_TEXT.length)
return layout
}

private companion object {
const val TAIL_TEXT = "Hello\ndecorated world"
const val CLIP_TEXT = "one two three four five six seven eight nine ten"
}
}
1 change: 1 addition & 0 deletions packages/react-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
"scripts/bundle.js",
"scripts/cocoapods",
"scripts/codegen",
"scripts/android",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"scripts/compose-source-maps.js",
"scripts/find-node-for-xcode.sh",
"scripts/generate-codegen-artifacts.js",
Expand Down
Loading