Skip to content

perf(stackflow): replace CSS animation with WAAPI for AppScreen transitions#1444

Draft
junghyeonsu wants to merge 11 commits intodevfrom
feature/des-1079-waapi
Draft

perf(stackflow): replace CSS animation with WAAPI for AppScreen transitions#1444
junghyeonsu wants to merge 11 commits intodevfrom
feature/des-1079-waapi

Conversation

@junghyeonsu
Copy link
Copy Markdown
Contributor

@junghyeonsu junghyeonsu commented Apr 9, 2026

Summary

  • Migrate all AppScreen transition animations (push, pop, swipe back completing/canceling) from CSS recipe-based seed-enter/seed-exit keyframes to Web Animations API (WAAPI)
  • Remove all transition animation selectors (push, pop, idle, swipeBack*) from app-screen.ts and app-bar.ts CSS recipes — static layout/color/z-index styles are preserved
  • Add transition-animation.ts with WAAPI implementations for all three transition styles (slideFromRightIOS, fadeFromBottomAndroid, fadeIn)
  • Rewrite useGlobalInteraction to detect stackflow transitionState changes and trigger WAAPI, replacing CSS variable cascade with explicit inline style management
  • Add rAF lock pattern in useSwipeBack for throttled touch event processing

Why

The previous CSS animation ↔ inline style handoff during swipe-back caused flicker, double-animation on exit-done, and AppBar desync bugs. Unifying all transitions under a single JS animation system eliminates the handoff entirely.

Test plan

  • bun test:all — 596 pass, 0 fail
  • Manual test in examples/stackflow-spa: push, pop, swipe complete, swipe cancel
  • Verify AppBar title/icon/background animate in sync during all transitions
  • Verify no flicker or double-animation on exit-done
  • Test rapid push → pop sequences

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com

Summary by CodeRabbit

  • 버그 수정

    • 스와이프 백 제스처 응답성 및 안정성 추가 개선 — 취소/완료 애니메이션 처리 및 상태 중복 방지
    • 화면 전환 중 시각적 깜박임 감소
  • 성능 개선

    • 애니메이션 렌더링 성능 최적화 및 RAF 기반 처리로 부드러움 향상
    • 네비게이션 상호작용 반응성 향상
  • 신규 기능

    • 전환·스와이프 애니메이션 처리 개선으로 더 일관된 이동·페이드 효과 제공

…creen transitions

Migrate all AppScreen transition animations (push, pop, swipe back) from
CSS recipe-based animations to Web Animations API (WAAPI). This eliminates
the CSS-to-JS handoff that caused flicker, double-animation, and timing bugs
during swipe-back gestures.

Key changes:
- Remove all transition animation selectors from app-screen.ts and app-bar.ts recipes
- Add transition-animation.ts with WAAPI implementations for iOS/Android/fadeIn styles
- Rewrite useGlobalInteraction to detect transitionState changes and trigger WAAPI
- Add rAF lock pattern and passive-ready touchmove in useSwipeBack
- Manage element positions via explicit inline styles instead of CSS variables

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 9, 2026

🦋 Changeset detected

Latest commit: 9718a76

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@seed-design/stackflow Patch
@seed-design/css Patch
@seed-design/figma Patch
@seed-design/mcp Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 9, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

스택플로우 전환 로직을 WAAPI 기반 애니메이션으로 재구성하고, 스와이프 백을 ref 기반으로 변경했으며, DOM 타겟 검색·인라인 스타일 유틸리티와 데이터 속성 식별자를 추가했습니다.

Changes

Cohort / File(s) Summary
Animation & DOM utilities
packages/stackflow/src/primitive/GlobalInteraction/animation.ts, packages/stackflow/src/primitive/GlobalInteraction/dom.ts
WAAPI 기반 전환·스와이프 애니메이션 구현 추가. 애니메이션 집계형 결과(AnimationResult), 취소 유틸, 전환/스와이프용 animate 함수들과 DOM 타겟 탐색·인라인 스타일 적용 함수(findTransitionTargets, applySwipeStyles, setPostExitPositions, 등) 추가.
Global interaction & swipe hooks
packages/stackflow/src/primitive/GlobalInteraction/useGlobalInteraction.ts, packages/stackflow/src/primitive/GlobalInteraction/useSwipeBack.ts
스와이프 상태를 state → ref로 전환, RAF 기반 스로틀링 추가, 제스처별 타겟 캐싱 및 WAAPI 호출로 완료/취소 애니메이션 구동, 인라인 스타일 설정·삭제 로직 및 애니메이션 취소 추적 도입.
AppBar DOM attributes
packages/stackflow/src/components/AppBar/AppBar.tsx, packages/stackflow/src/primitive/AppBar/AppBar.tsx
AppBar 관련 Slot/요소에 data-part 속성(appBarIcon, appBarCustom, appBarMain)을 추가하여 DOM에서 전환 타겟을 식별하도록 변경.
Preset variant cleanup
packages/qvism-preset/src/stackflow/app-bar.ts, packages/qvism-preset/src/stackflow/app-screen.ts
애니메이션 상수 및 pseudo-transition 키 imports 제거. slideFromRightIOS, fadeFromBottomAndroid, fadeIn 변형을 내부 스타일 매핑에서 비워 전환 스타일의 내부 매핑 제거.

Sequence Diagram(s)

sequenceDiagram
    participant User as 사용자 제스처
    participant SB as useSwipeBack
    participant GI as useGlobalInteraction
    participant DOM as DOM 유틸리티
    participant WAAPI as WAAPI 애니메이션

    User->>SB: touchmove / touchend 이벤트
    SB->>SB: RAF 스로틀 확인
    SB->>GI: events.moveSwipeBack / events.endSwipeBack
    GI->>DOM: findTransitionTargets(stackEl)
    GI->>DOM: applySwipeStyles(targets, displacement, ratio)
    DOM->>DOM: 인라인 transform/opacity 설정
    alt 스와이프 종료(완료)
        GI->>WAAPI: animateSwipeComplete(targets, displacement, velocity)
        WAAPI->>DOM: 애니메이션으로 transform/opacity 변화
        WAAPI-->>GI: finished Promise
    else 스와이프 종료(취소)
        GI->>WAAPI: animateSwipeCancel(targets, displacement, velocity)
        WAAPI->>DOM: 복귀 애니메이션 실행
        WAAPI-->>GI: finished Promise
    end
    GI->>DOM: setPostExitPositions(targets, style)
    GI->>GI: 상태 및 인라인 스타일 정리
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 춤추는 발톱으로 말하네
WAAPI로 길을 닦고, 스와이프를 품어
DOM을 찾아 속성을 속삭이며,
ref에 숨결을 담아 움직임을 완성하네.
새로운 전환이 퐁당, 기쁘게 번진다.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목이 변경사항의 핵심을 명확하게 나타냅니다. CSS 애니메이션에서 WAAPI로의 마이그레이션이 주요 변경사항이며, 제목은 이를 정확하게 반영합니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/des-1079-waapi

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 and usage tips.

@junghyeonsu
Copy link
Copy Markdown
Contributor Author

/snapshot

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 9, 2026

Alpha Preview (Stackflow SPA)

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 9, 2026

Alpha Preview (Storybook)

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 9, 2026

📦 Snapshot Release

@seed-design/cli: https://pkg.pr.new/@seed-design/cli@0217ba3
@seed-design/codemod: https://pkg.pr.new/@seed-design/codemod@0217ba3
@seed-design/css: https://pkg.pr.new/@seed-design/css@0217ba3
@seed-design/design-token: https://pkg.pr.new/@seed-design/design-token@0217ba3
@seed-design/docs-mcp: https://pkg.pr.new/@seed-design/docs-mcp@0217ba3
@seed-design/figma: https://pkg.pr.new/@seed-design/figma@0217ba3
@seed-design/mcp: https://pkg.pr.new/@seed-design/mcp@0217ba3
@seed-design/migration-index: https://pkg.pr.new/@seed-design/migration-index@0217ba3
@seed-design/react: https://pkg.pr.new/@seed-design/react@0217ba3
@seed-design/rootage-artifacts: https://pkg.pr.new/@seed-design/rootage-artifacts@0217ba3
@seed-design/rsbuild-plugin: https://pkg.pr.new/@seed-design/rsbuild-plugin@0217ba3
@seed-design/stackflow: https://pkg.pr.new/@seed-design/stackflow@0217ba3
@seed-design/stylesheet: https://pkg.pr.new/@seed-design/stylesheet@0217ba3
@seed-design/tailwind3-plugin: https://pkg.pr.new/@seed-design/tailwind3-plugin@0217ba3
@seed-design/tailwind4-theme: https://pkg.pr.new/@seed-design/tailwind4-theme@0217ba3
@seed-design/vite-plugin: https://pkg.pr.new/@seed-design/vite-plugin@0217ba3
@seed-design/webpack-plugin: https://pkg.pr.new/@seed-design/webpack-plugin@0217ba3
@seed-design/react-avatar: https://pkg.pr.new/@seed-design/react-avatar@0217ba3
@seed-design/react-checkbox: https://pkg.pr.new/@seed-design/react-checkbox@0217ba3
@seed-design/react-collapsible: https://pkg.pr.new/@seed-design/react-collapsible@0217ba3
@seed-design/react-dialog: https://pkg.pr.new/@seed-design/react-dialog@0217ba3
@seed-design/react-drawer: https://pkg.pr.new/@seed-design/react-drawer@0217ba3
@seed-design/react-field: https://pkg.pr.new/@seed-design/react-field@0217ba3
@seed-design/react-field-button: https://pkg.pr.new/@seed-design/react-field-button@0217ba3
@seed-design/react-fieldset: https://pkg.pr.new/@seed-design/react-fieldset@0217ba3
@seed-design/react-image: https://pkg.pr.new/@seed-design/react-image@0217ba3
@seed-design/react-popover: https://pkg.pr.new/@seed-design/react-popover@0217ba3
@seed-design/react-portal: https://pkg.pr.new/@seed-design/react-portal@0217ba3
@seed-design/react-primitive: https://pkg.pr.new/@seed-design/react-primitive@0217ba3
@seed-design/react-progress: https://pkg.pr.new/@seed-design/react-progress@0217ba3
@seed-design/react-pull-to-refresh: https://pkg.pr.new/@seed-design/react-pull-to-refresh@0217ba3
@seed-design/react-radio-group: https://pkg.pr.new/@seed-design/react-radio-group@0217ba3
@seed-design/react-scrollable: https://pkg.pr.new/@seed-design/react-scrollable@0217ba3
@seed-design/react-segmented-control: https://pkg.pr.new/@seed-design/react-segmented-control@0217ba3
@seed-design/react-slider: https://pkg.pr.new/@seed-design/react-slider@0217ba3
@seed-design/react-snackbar: https://pkg.pr.new/@seed-design/react-snackbar@0217ba3
@seed-design/react-supports: https://pkg.pr.new/@seed-design/react-supports@0217ba3
@seed-design/react-switch: https://pkg.pr.new/@seed-design/react-switch@0217ba3
@seed-design/react-tabs: https://pkg.pr.new/@seed-design/react-tabs@0217ba3
@seed-design/react-text-field: https://pkg.pr.new/@seed-design/react-text-field@0217ba3
@seed-design/react-toggle: https://pkg.pr.new/@seed-design/react-toggle@0217ba3
@seed-design/react-use-controllable-state: https://pkg.pr.new/@seed-design/react-use-controllable-state@0217ba3
@seed-design/dom-utils: https://pkg.pr.new/@seed-design/dom-utils@0217ba3

Triggered by @junghyeonsu via /snapshot

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/stackflow/src/primitive/GlobalInteraction/useSwipeBack.ts (1)

31-46: ⚠️ Potential issue | 🟠 Major

마지막 touchmove를 종료 전에 반영해야 합니다.

지금은 moveSwipeBack()가 다음 frame으로 미뤄지기 때문에, 그 전에 onTouchEnd/onTouchCancel이 오면 endSwipeBack()가 이전 displacement/velocity로 판정합니다. 빠른 스와이프가 취소로 오판될 수 있고, 다음 frame의 지연된 moveSwipeBack()이 이미 시작된 cancel/completing 애니메이션 위에 다시 스타일을 덮어쓸 수도 있습니다. 마지막 좌표와 rAF id를 저장해 두고 종료 시 동기 flush 하거나, 최소한 pending frame을 취소한 뒤 종료하는 쪽이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stackflow/src/primitive/GlobalInteraction/useSwipeBack.ts` around
lines 31 - 46, The touchend/touchcancel path can race with a pending
requestAnimationFrame in onTouchMove, causing endSwipeBack() to use stale
displacement/velocity; modify useSwipeBack so onTouchMove saves the last touch
{x,t} and the rAF id (e.g. rafIdRef) when calling requestAnimationFrame, and
then in onTouchEnd and onTouchCancel first cancel any pending rAF
(cancelAnimationFrame(rafIdRef.current)) and synchronously flush the last saved
touch by calling events.moveSwipeBack with the saved {x,t} before calling
events.endSwipeBack({}), ensuring rAFLockRef is reset appropriately to avoid
deadlock.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/qvism-preset/src/stackflow/app-bar.ts`:
- Around line 231-235: 현재 transitionStyle의 slideFromRightIOS /
fadeFromBottomAndroid / fadeIn 변형을 비워서 transition-animation.ts의
applySwipeStyles()가 업데이트하는 CSS 변수 --swipe-back-displacement를 읽는 transform 동작이
사라졌습니다; transitionStyle 객체의 해당 variants에 swipe 중 요소 이동을 반영하는 transform(예:
translateX(var(--swipe-back-displacement))) 규칙을 복원하고, 이 파일의 AppBar 스타일 내
&:before(배경)에도 같은 transform(translateX(var(--swipe-back-displacement)))을 적용해
제목/아이콘과 동일하게 배경이 손가락을 따라 움직이도록 만드세요; applySwipeStyles()가 설정하는
--swipe-back-displacement 변수를 사용하도록 정확한 변수 이름을 참조하는지 확인하세요.

In `@packages/stackflow/src/primitive/GlobalInteraction/transition-animation.ts`:
- Around line 250-259: The problem is waitAll treats canceled animations as
successful by swallowing AbortError, causing callers (useGlobalInteraction.ts
callbacks that call setIdlePositions()/setPostExitPositions()) to run for
canceled animations; update waitAll to rethrow AbortError so Promise.all rejects
on cancellation (i.e., change the finished.catch handler inside waitAll to
rethrow when err.name === 'AbortError' or instanceof DOMException with name
'AbortError', but continue swallowing/ignoring other errors), ensuring canceled
animations are identified and downstream cleanup callbacks are skipped.

In `@packages/stackflow/src/primitive/GlobalInteraction/useGlobalInteraction.ts`:
- Around line 311-321: The returned swipeBackState currently exposes a snapshot
(swipeBackStateRef.current) so consumers never see updates when
setSwipeBackState is called; update the API by either removing swipeBackState
from the returned object or making it reactive — e.g., add a stable getter
getSwipeBackState() or maintain a synced piece of state inside
useGlobalInteraction that is updated inside setSwipeBackState (keep
setSwipeBackState, getSwipeBackEvents, stackProps as-is), and update the useMemo
to return the reactive getter/value (or remove swipeBackState) so consumers
observe changes; refer to useGlobalInteraction, swipeBackStateRef,
setSwipeBackState and getSwipeBackEvents when implementing.
- Around line 96-119: When starting a swipe in
useGlobalInteraction.startSwipeBack you cancel runningAnimsRef but you also must
cancel any scheduled frame-based push WAAPI reserved via pendingRAFRef; if you
don't, that RAF can fire after the swipe begins and apply push styles over the
swipe. Fix by checking pendingRAFRef.current in startSwipeBack, call
cancelAnimationFrame on it (or clearTimeout if using setTimeout), set
pendingRAFRef.current = null, and ensure any associated queued push state is
cleared so the reserved push won't run during the swipe.

---

Outside diff comments:
In `@packages/stackflow/src/primitive/GlobalInteraction/useSwipeBack.ts`:
- Around line 31-46: The touchend/touchcancel path can race with a pending
requestAnimationFrame in onTouchMove, causing endSwipeBack() to use stale
displacement/velocity; modify useSwipeBack so onTouchMove saves the last touch
{x,t} and the rAF id (e.g. rafIdRef) when calling requestAnimationFrame, and
then in onTouchEnd and onTouchCancel first cancel any pending rAF
(cancelAnimationFrame(rafIdRef.current)) and synchronously flush the last saved
touch by calling events.moveSwipeBack with the saved {x,t} before calling
events.endSwipeBack({}), ensuring rAFLockRef is reset appropriately to avoid
deadlock.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8d7b97e8-99d6-48cc-9bc7-e462e0c12b96

📥 Commits

Reviewing files that changed from the base of the PR and between 9af1f1d and 0217ba3.

⛔ Files ignored due to path filters (10)
  • packages/css/all.css is excluded by !packages/css/**/*
  • packages/css/all.layered.css is excluded by !packages/css/**/*
  • packages/css/all.layered.min.css is excluded by !packages/css/**/*
  • packages/css/all.min.css is excluded by !packages/css/**/*
  • packages/css/recipes/app-bar-main.css is excluded by !packages/css/**/*
  • packages/css/recipes/app-bar-main.layered.css is excluded by !packages/css/**/*
  • packages/css/recipes/app-bar.css is excluded by !packages/css/**/*
  • packages/css/recipes/app-bar.layered.css is excluded by !packages/css/**/*
  • packages/css/recipes/app-screen.css is excluded by !packages/css/**/*
  • packages/css/recipes/app-screen.layered.css is excluded by !packages/css/**/*
📒 Files selected for processing (5)
  • packages/qvism-preset/src/stackflow/app-bar.ts
  • packages/qvism-preset/src/stackflow/app-screen.ts
  • packages/stackflow/src/primitive/GlobalInteraction/transition-animation.ts
  • packages/stackflow/src/primitive/GlobalInteraction/useGlobalInteraction.ts
  • packages/stackflow/src/primitive/GlobalInteraction/useSwipeBack.ts

Comment thread packages/qvism-preset/src/stackflow/app-bar.ts
Comment thread packages/stackflow/src/primitive/GlobalInteraction/animation.ts
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 9, 2026

Alpha Preview (Docs)

Extract shared helpers to reduce duplication without changing behavior:
- Unify iosAnimatePush/Pop into parameterized iosAnimate
- Unify animateSwipeComplete/Cancel into parameterized animateSwipe
- Extract collectAnimations helper for repeated filter+waitAll pattern
- Fix file header comment to match actual fill:"forwards" strategy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/stackflow/src/primitive/GlobalInteraction/transition-animation.ts (1)

346-352: 타입 단언 대신 명시적 타입 정의를 고려해보세요.

from.topTitle["transform"] as string 패턴은 현재 IOS_ONSCREEN/IOS_OFFSCREEN 상수에서 안전하지만, 향후 유지보수 시 타입 안전성을 저해할 수 있습니다.

♻️ 타입 안전성 개선 제안
 interface IosPositions {
   topLayer: string;
   behindLayer: string;
   dim: string;
-  topTitle: Keyframe;
-  behindTitle: Keyframe;
+  topTitle: { opacity: string; transform: string };
+  behindTitle: { opacity: string; transform: string };
   topIconOpacity: string;
   behindIconOpacity: string;
   appBarPseudo: string;
 }

이렇게 하면 from.topTitle.transform으로 직접 접근 가능하며 타입 단언이 불필요해집니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stackflow/src/primitive/GlobalInteraction/transition-animation.ts`
around lines 346 - 352, The code uses type assertions like
from.topTitle["transform"] as string; instead, update the type definition for
the topTitle object so transform is explicitly typed as string (e.g., refine the
TopTitle type/interface or the IOS_ONSCREEN/IOS_OFFSCREEN constant typings) and
then replace bracket-assertions with direct property access
(from.topTitle.transform and to.topTitle.transform) to eliminate the need for
"as string" and preserve type safety.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/stackflow/src/primitive/GlobalInteraction/transition-animation.ts`:
- Around line 346-352: The code uses type assertions like
from.topTitle["transform"] as string; instead, update the type definition for
the topTitle object so transform is explicitly typed as string (e.g., refine the
TopTitle type/interface or the IOS_ONSCREEN/IOS_OFFSCREEN constant typings) and
then replace bracket-assertions with direct property access
(from.topTitle.transform and to.topTitle.transform) to eliminate the need for
"as string" and preserve type safety.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 43afaef9-959e-48c7-9b48-e69533413d0d

📥 Commits

Reviewing files that changed from the base of the PR and between 0217ba3 and e61a565.

📒 Files selected for processing (1)
  • packages/stackflow/src/primitive/GlobalInteraction/transition-animation.ts

setIdlePositions and setPostExitPositions now accept TransitionStyle
parameter. iOS-specific behind layer offset (-30%) and title/icon
hiding only apply to slideFromRightIOS. Android and fadeIn transitions
no longer incorrectly offset the behind activity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/stackflow/src/primitive/GlobalInteraction/transition-animation.ts (1)

34-37: 클래스 substring 셀렉터 사용에 대한 참고

[class*="seed-app-bar..."] 패턴은 CSS-in-JS 환경에서 클래스명에 hash가 붙는 경우를 대응하기 위한 것으로 보입니다. 다만 이 방식은:

  • 유사한 이름의 다른 클래스와 우발적 매칭 가능성
  • 클래스 네이밍 컨벤션 변경 시 깨질 수 있음

현재 구현이 의도된 것이라면 문제없으나, 가능하다면 data-part 속성처럼 명시적 attribute 기반 셀렉터가 더 안정적입니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stackflow/src/primitive/GlobalInteraction/transition-animation.ts`
around lines 34 - 37, The substring class selectors (SEL_APP_BAR_MAIN_ROOT,
SEL_APP_BAR_ROOT, SEL_APP_BAR_ICON, SEL_APP_BAR_CUSTOM) are brittle and may
match unintended hashed classnames; change the selectors to use an explicit data
attribute (e.g., data-part="app-bar-main", "app-bar-root", "app-bar-icon",
"app-bar-custom") and update the consuming components to render those data-part
attributes so the animation code queries by attribute instead of class
substring; ensure the new attribute names are documented and replace all uses of
the four SEL_* constants in transition-animation.ts with the corresponding
data-part selectors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/stackflow/src/primitive/GlobalInteraction/useGlobalInteraction.ts`:
- Around line 87-230: The getSwipeBackEvents function violates React Hooks rules
by calling hooks (useCallbackRef, useCallback, useMemo) inside a callback; move
all hook calls to the top level of the useGlobalInteraction hook and have
getSwipeBackEvents only compose/return pre-created callbacks. Concretely: hoist
useCallbackRef calls for props.onSwipeBackStart/onSwipeBackMove/onSwipeBackEnd
out of getSwipeBackEvents into the top of useGlobalInteraction (keep names
onSwipeStartRef/onSwipeMoveRef/onSwipeEndRef or similar), create top-level
stable callbacks startSwipeBack, moveSwipeBack, endSwipeBack and reset with
useCallback that reference refs and refs like
swipeBackContextRef/cachedTargetsRef/runningAnimsRef, and create the returned
object with useMemo at top-level; then change getSwipeBackEvents to simply
return { startSwipeBack, moveSwipeBack, endSwipeBack, reset } (or remove it and
return that object directly) so no hooks are invoked inside a nested callback.

---

Nitpick comments:
In `@packages/stackflow/src/primitive/GlobalInteraction/transition-animation.ts`:
- Around line 34-37: The substring class selectors (SEL_APP_BAR_MAIN_ROOT,
SEL_APP_BAR_ROOT, SEL_APP_BAR_ICON, SEL_APP_BAR_CUSTOM) are brittle and may
match unintended hashed classnames; change the selectors to use an explicit data
attribute (e.g., data-part="app-bar-main", "app-bar-root", "app-bar-icon",
"app-bar-custom") and update the consuming components to render those data-part
attributes so the animation code queries by attribute instead of class
substring; ensure the new attribute names are documented and replace all uses of
the four SEL_* constants in transition-animation.ts with the corresponding
data-part selectors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: eff0c742-5717-4f8e-856c-92761d806799

📥 Commits

Reviewing files that changed from the base of the PR and between e61a565 and ed6b982.

📒 Files selected for processing (2)
  • packages/stackflow/src/primitive/GlobalInteraction/transition-animation.ts
  • packages/stackflow/src/primitive/GlobalInteraction/useGlobalInteraction.ts

…on.ts

- Add data-part attributes to AppBarMain, AppBarIconButton, AppBarSlot
  to replace fragile className-based selectors
- Split transition-animation.ts into:
  - dom.ts: DOM discovery (findTransitionTargets) and inline style management
  - animation.ts: WAAPI animation functions
- Unify SwipeEndpoints with IOS_ONSCREEN/IOS_OFFSCREEN constants
- Remove transition-animation.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (4)
packages/stackflow/src/primitive/GlobalInteraction/useGlobalInteraction.ts (3)

98-121: ⚠️ Potential issue | 🟠 Major

swipe 시작 시 대기 중인 push rAF도 취소해야 합니다.

Lines 261-263에서 enter-active는 다음 frame에 push WAAPI를 예약하는데, startSwipeBack에서는 실행 중인 animation만 취소하고 pendingRAFRef는 그대로 둡니다. 사용자가 그 frame 전에 edge swipe를 시작하면 예약된 push가 뒤늦게 실행되어 swipe 스타일과 겹칠 수 있습니다.

,

권장 수정
     const startSwipeBack = useCallback(
       ({ x0, t0 }: StartSwipeBackProps) => {
+        // Cancel any pending RAF-scheduled push animation
+        if (pendingRAFRef.current !== null) {
+          cancelAnimationFrame(pendingRAFRef.current);
+          pendingRAFRef.current = null;
+        }
+
         // Cancel any running transition animations
         cancelAll(runningAnimsRef.current);
         runningAnimsRef.current = [];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stackflow/src/primitive/GlobalInteraction/useGlobalInteraction.ts`
around lines 98 - 121, startSwipeBack currently cancels only running animations
via cancelAll(runningAnimsRef.current) but does not clear any scheduled frame
callbacks, so pending push WAAPI scheduled by enter-active (via pendingRAFRef)
can still run during a swipe; update startSwipeBack to also cancel and clear
pendingRAFRef (e.g., call cancelAnimationFrame or equivalent on
pendingRAFRef.current and set pendingRAFRef.current = null) and ensure any
helper that schedules push WAAPI uses pendingRAFRef so this cleanup prevents the
delayed push from applying styles during a swipe.

313-324: ⚠️ Potential issue | 🟠 Major

반환하는 swipeBackState는 reactive하지 않습니다.

Line 317에서 swipeBackStateRef.current 스냅샷을 그대로 반환하므로, setSwipeBackState()가 호출되어도 hook 소비자는 변경된 값을 받지 못합니다. DOM dataset 갱신과는 별개로, 공개 API로 유지할 경우 reactive하게 노출하거나 API surface에서 제거하는 것이 안전합니다.

,

권장 수정 옵션

옵션 1: 제거 — 외부에서 필요 없다면 반환 객체에서 swipeBackState 제거

옵션 2: getter 함수로 노출

   return useMemo(
     () => ({
       stackRef,
       swipeBackContextRef,
-      swipeBackState: swipeBackStateRef.current,
+      getSwipeBackState: () => swipeBackStateRef.current,
       setSwipeBackState,
       getSwipeBackEvents,
       stackProps,
     }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stackflow/src/primitive/GlobalInteraction/useGlobalInteraction.ts`
around lines 313 - 324, The returned swipeBackState currently returns a
non-reactive snapshot (swipeBackStateRef.current) from useGlobalInteraction;
update the API so consumers see updates by either removing swipeBackState from
the returned object or replacing it with a getter function that reads
swipeBackStateRef.current (e.g., expose getSwipeBackState) or convert to React
state updated by setSwipeBackState; adjust the returned object (which currently
lists stackRef, swipeBackContextRef, swipeBackState: swipeBackStateRef.current,
setSwipeBackState, getSwipeBackEvents, stackProps) and its dependency array
accordingly so consumers receive reactive updates or the property is no longer
exposed.

89-232: ⚠️ Potential issue | 🔴 Critical

React Hooks 규칙 위반: 콜백 내부에서 hooks 호출

getSwipeBackEventsuseCallback으로 감싸진 함수인데, 내부에서 useCallbackRef (lines 94-96), useCallback (lines 98, 123, 147, 206), useMemo (line 223)를 호출하고 있습니다. Hooks는 반드시 컴포넌트/커스텀 hook의 최상위에서만 호출해야 합니다.

hooks를 useGlobalInteraction 최상위로 이동하고, getSwipeBackEvents는 이미 생성된 callbacks를 조합하여 반환하도록 리팩토링이 필요합니다.

,

권장 리팩토링 방향
 export function useGlobalInteraction() {
   const swipeBackStateRef = useRef<SwipeBackState>("idle");
   // ...existing refs...

+  // Move callback refs to top level
+  const onSwipeStartRef = useRef<(() => void) | undefined>();
+  const onSwipeMoveRef = useRef<((props: { displacement: number; displacementRatio: number }) => void) | undefined>();
+  const onSwipeEndRef = useRef<((props: { swiped: boolean }) => void) | undefined>();
+
+  // Move callbacks to top level
+  const startSwipeBack = useCallback(({ x0, t0 }: StartSwipeBackProps) => {
+    // ... implementation using onSwipeStartRef.current ...
+  }, []);
+
+  // ... other callbacks at top level ...

   const getSwipeBackEvents = useCallback((props: SwipeBackProps) => {
-    const onSwipeStart = useCallbackRef(props.onSwipeBackStart);
+    // Update refs instead of creating new hooks
+    onSwipeStartRef.current = props.onSwipeBackStart;
+    onSwipeMoveRef.current = props.onSwipeBackMove;
+    onSwipeEndRef.current = props.onSwipeBackEnd;
+
+    return { startSwipeBack, moveSwipeBack, endSwipeBack, reset };
   }, [startSwipeBack, moveSwipeBack, endSwipeBack, reset]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stackflow/src/primitive/GlobalInteraction/useGlobalInteraction.ts`
around lines 89 - 232, getSwipeBackEvents currently calls hooks (useCallbackRef,
useCallback, useMemo) inside its body which violates React Hooks rules; move
those hook calls to the top-level of the containing useGlobalInteraction hook
and have getSwipeBackEvents only compose and return the already-created
callbacks. Concretely: lift the useCallbackRef(...) for
onSwipeStart/onSwipeMove/onSwipeEnd and the useCallback definitions for
startSwipeBack, moveSwipeBack, endSwipeBack, reset, and the useMemo return
wrapper out of getSwipeBackEvents into useGlobalInteraction's top scope; keep
the internal logic of startSwipeBack/moveSwipeBack/endSwipeBack/reset identical
but reference shared refs (swipeBackContextRef, cachedTargetsRef,
runningAnimsRef, skipNextExitRef, setSwipeBackState, stackRef) from the outer
hook, and change getSwipeBackEvents to simply return { startSwipeBack,
moveSwipeBack, endSwipeBack, reset } (or a memoized object using the
already-lifted useMemo) so no hooks are invoked inside getSwipeBackEvents
itself.
packages/stackflow/src/primitive/GlobalInteraction/animation.ts (1)

68-78: ⚠️ Potential issue | 🟠 Major

cancel()된 animation도 정상 완료처럼 처리됩니다.

waitAllAbortError를 삼킨 뒤 resolve하므로, 호출부의 finished.then(...)이 cancel 경로에서도 항상 실행됩니다. useGlobalInteraction.ts에서는 그 callback 안에서 setIdlePositions()/setPostExitPositions()를 호출하므로, 빠른 push→pop 전환 시 취소된 이전 animation의 callback이 뒤늦게 스타일을 덮어쓸 수 있습니다.

,

권장 수정

cancel 여부를 식별하여 후속 cleanup을 건너뛰도록 수정:

 function waitAll(animations: (Animation | null)[]): Promise<void> {
   const valid = animations.filter((a): a is Animation => a !== null);
   if (valid.length === 0) return Promise.resolve();
   return Promise.all(
     valid.map((a) =>
       a.finished.catch((err) => {
-        /* AbortError from cancel */
+        // Re-throw AbortError so callers can distinguish cancellation
+        if (err instanceof DOMException && err.name === "AbortError") {
+          throw err;
+        }
+        // Swallow other errors
       }),
     ),
   ).then(() => {});
 }

또는 호출부에서 animation 참조가 유효한지 확인:

finished.then(() => {
  // Only apply if these are still the current animations
  if (runningAnimsRef.current === animations) {
    setIdlePositions(targets, style);
    cancelAll(animations);
    runningAnimsRef.current = [];
  }
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stackflow/src/primitive/GlobalInteraction/animation.ts` around lines
68 - 78, waitAll currently swallows AbortError from cancelled animations so
callers' finished.then callbacks run even for cancelled sequences; change
waitAll to rethrow AbortError (or otherwise propagate cancellation) instead of
swallowing it: inside waitAll's Promise.all mapping for each Animation a use
a.finished.catch(err => { if (err && err.name === 'AbortError') throw err; /*
swallow other errors if desired */ }); this preserves the existing behavior for
non-cancellation errors but ensures cancelled animations cause the returned
promise to reject so callers (e.g. useGlobalInteraction.ts finished handlers)
can skip cleanup or verify runningAnimsRef/current animations before applying
style changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/stackflow/src/primitive/GlobalInteraction/animation.ts`:
- Around line 68-78: waitAll currently swallows AbortError from cancelled
animations so callers' finished.then callbacks run even for cancelled sequences;
change waitAll to rethrow AbortError (or otherwise propagate cancellation)
instead of swallowing it: inside waitAll's Promise.all mapping for each
Animation a use a.finished.catch(err => { if (err && err.name === 'AbortError')
throw err; /* swallow other errors if desired */ }); this preserves the existing
behavior for non-cancellation errors but ensures cancelled animations cause the
returned promise to reject so callers (e.g. useGlobalInteraction.ts finished
handlers) can skip cleanup or verify runningAnimsRef/current animations before
applying style changes.

In `@packages/stackflow/src/primitive/GlobalInteraction/useGlobalInteraction.ts`:
- Around line 98-121: startSwipeBack currently cancels only running animations
via cancelAll(runningAnimsRef.current) but does not clear any scheduled frame
callbacks, so pending push WAAPI scheduled by enter-active (via pendingRAFRef)
can still run during a swipe; update startSwipeBack to also cancel and clear
pendingRAFRef (e.g., call cancelAnimationFrame or equivalent on
pendingRAFRef.current and set pendingRAFRef.current = null) and ensure any
helper that schedules push WAAPI uses pendingRAFRef so this cleanup prevents the
delayed push from applying styles during a swipe.
- Around line 313-324: The returned swipeBackState currently returns a
non-reactive snapshot (swipeBackStateRef.current) from useGlobalInteraction;
update the API so consumers see updates by either removing swipeBackState from
the returned object or replacing it with a getter function that reads
swipeBackStateRef.current (e.g., expose getSwipeBackState) or convert to React
state updated by setSwipeBackState; adjust the returned object (which currently
lists stackRef, swipeBackContextRef, swipeBackState: swipeBackStateRef.current,
setSwipeBackState, getSwipeBackEvents, stackProps) and its dependency array
accordingly so consumers receive reactive updates or the property is no longer
exposed.
- Around line 89-232: getSwipeBackEvents currently calls hooks (useCallbackRef,
useCallback, useMemo) inside its body which violates React Hooks rules; move
those hook calls to the top-level of the containing useGlobalInteraction hook
and have getSwipeBackEvents only compose and return the already-created
callbacks. Concretely: lift the useCallbackRef(...) for
onSwipeStart/onSwipeMove/onSwipeEnd and the useCallback definitions for
startSwipeBack, moveSwipeBack, endSwipeBack, reset, and the useMemo return
wrapper out of getSwipeBackEvents into useGlobalInteraction's top scope; keep
the internal logic of startSwipeBack/moveSwipeBack/endSwipeBack/reset identical
but reference shared refs (swipeBackContextRef, cachedTargetsRef,
runningAnimsRef, skipNextExitRef, setSwipeBackState, stackRef) from the outer
hook, and change getSwipeBackEvents to simply return { startSwipeBack,
moveSwipeBack, endSwipeBack, reset } (or a memoized object using the
already-lifted useMemo) so no hooks are invoked inside getSwipeBackEvents
itself.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e86140b6-9815-4f70-b4e5-b756c483a086

📥 Commits

Reviewing files that changed from the base of the PR and between ed6b982 and 8778bef.

📒 Files selected for processing (5)
  • packages/stackflow/src/components/AppBar/AppBar.tsx
  • packages/stackflow/src/primitive/AppBar/AppBar.tsx
  • packages/stackflow/src/primitive/GlobalInteraction/animation.ts
  • packages/stackflow/src/primitive/GlobalInteraction/dom.ts
  • packages/stackflow/src/primitive/GlobalInteraction/useGlobalInteraction.ts
✅ Files skipped from review due to trivial changes (2)
  • packages/stackflow/src/primitive/AppBar/AppBar.tsx
  • packages/stackflow/src/components/AppBar/AppBar.tsx

junghyeonsu and others added 7 commits April 10, 2026 00:23
…sitions

Set start positions as inline styles before animation begins for
fadeFromBottomAndroid (opacity:0 + translateY:8vh) and fadeIn (opacity:0),
matching the existing pattern in iOS push.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…state

useTopActivity().transitionStyle updates one render cycle late because it
uses useState + useLayoutEffect. During push, the captured value was the
previous activity's style, so fadeFromBottomAndroid/fadeIn always fell back
to slideFromRightIOS.

Add readTransitionStyle() that reads data-transition-style directly from
the top activity DOM element at animation time, bypassing the stale state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
For fadeFromBottomAndroid and fadeIn transitions, the behind activity
stays in place (no parallax offset). After push completes, its appBar
was visible behind the top activity's appBar. Hide it with opacity:0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ment

Root cause: clearAllStyles didn't clear transform/opacity on appBar roots,
leaving stale inline styles from pre-animation setup (e.g. Android push
sets transform:translate3d(0,8vh,0) which was never cleaned up).

Pattern change: setIdlePositions and setPostExitPositions now call
clearAllStyles first (clean slate), then set only non-default positions.
No more selective clearing that misses elements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Behind appBar is hidden (opacity:0) during idle to prevent bleed-through.
Pop must restore it before animation starts so it doesn't flash in at the end.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
If a push rAF is scheduled but hasn't fired yet when the user starts
swiping, the rAF would execute after swipe begins and overlay push
animations on top of swipe styles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@malangcat
Copy link
Copy Markdown
Contributor

good

@junghyeonsu junghyeonsu marked this pull request as draft April 15, 2026 03:35
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.

2 participants