Skip to content

feat(snackbar): add immediate/queued strategy for multi-snackbar behavior#1413

Open
te6-in wants to merge 13 commits intodevfrom
feat/snackbar-immediate-strategy
Open

feat(snackbar): add immediate/queued strategy for multi-snackbar behavior#1413
te6-in wants to merge 13 commits intodevfrom
feat/snackbar-immediate-strategy

Conversation

@te6-in
Copy link
Copy Markdown
Member

@te6-in te6-in commented Apr 3, 2026

Summary by CodeRabbit

  • 새로운 기능

    • 스낵바 표시 전략 추가: 기존 스낵바를 즉시 교체하거나 순차 대기열로 처리하는 옵션 제공
    • 런타임에서 개별 스낵바에 전략을 지정할 수 있음
  • 문서

    • 전략 사용법과 예제 문서 추가(프로바이더 기본 설정 및 개별 오버라이드 설명)
  • 테스트

    • 스낵바 생명주기 및 전략 동작을 검증하는 통합 테스트 추가
  • 데모

    • 앱 데모에 전략 선택 및 대기열 동작 확인용 항목 추가

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 3, 2026

⚠️ No Changeset found

Latest commit: 4c5dde6

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

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

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 3, 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

Snackbar 생성 시 기존 표시 항목 처리 전략이 도입되어, 프로바이더 기본값과 개별 생성 옵션으로 immediate(기본) 또는 queued 동작을 선택하도록 문서, 예제, 핵심 로직, 어댑터, 및 테스트가 추가·수정되었습니다. (50자 내외)

Changes

Cohort / File(s) Summary
문서화 및 예제
docs/content/react/components/snackbar.mdx, docs/examples/react/snackbar/strategy.tsx, examples/stackflow-spa/src/activities/ActivityHome.tsx
strategy 섹션 및 예제 추가; SPA 네비게이션에 queued 및 워크어라운드 예제 항목 추가.
핵심 상태 로직
packages/react-headless/snackbar/src/useSnackbar.ts
Provider 레벨 `strategy?: "immediate"
프로바이더/어댑터 통합
packages/react-headless/snackbar/src/Snackbar.tsx, packages/react/src/components/Snackbar/useSnackbarAdapter.ts
SnackbarRootProvider가 남은 props를 useSnackbar()로 전달하도록 변경되어 provider-level 옵션 확장; 어댑터의 createstrategy 옵션을 포워딩하도록 수정.
테스트 추가
packages/react-headless/snackbar/src/useSnackbar.test.tsx
통합/시나리오 테스트 추가: lifecycle(timeout/removeDelay), dismiss, 즉시 교체·큐 동작, provider 기본값과 per-snackbar 오버라이드 검증.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Component
    participant SnackbarProvider
    participant useSnackbarState
    participant SnackbarRenderer

    User->>Component: 클릭 → adapter.create(options)
    Component->>SnackbarProvider: create(options)
    SnackbarProvider->>useSnackbarState: push(option { strategy? })
    alt option.strategy == "immediate"
        useSnackbarState->>SnackbarRenderer: 현재 교체하거나 즉시 활성화
    else option.strategy == "queued"
        useSnackbarState->>useSnackbarState: 큐에 추가 (대기)
        note right of useSnackbarState: 비활성 시 큐에서 pop → activate
        useSnackbarState->>SnackbarRenderer: 순차적 활성화
    end
    SnackbarRenderer->>User: 표시 → timeout → dismiss() → removeDelay → 제거
Loading

예상 코드 리뷰 난이도

🎯 4 (Complex) | ⏱️ ~45 minutes

🐰 새벽에 폴짝, 벨 눌렀네
즉시 달려들까, 줄 서서 기다릴까
클릭 한 번에 전략을 골라
반짝임은 바뀌고, 줄은 흐르고
스낵바는 웃으며 사라지네 ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% 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 The pull request title directly describes the main feature addition: implementing immediate/queued strategy options for controlling how multiple snackbars are displayed.

✏️ 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 feat/snackbar-immediate-strategy

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.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 3, 2026

Alpha Preview (Stackflow SPA)

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 3, 2026

Alpha Preview (Storybook)

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 3, 2026

Alpha Preview (Docs)

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/react-headless/snackbar/src/useSnackbar.ts (1)

117-136: "immediate" 모드에서 대기열에 있던 스낵바의 onClose 콜백이 호출되지 않을 수 있습니다.

state가 "inactive"가 아닐 때 setQueue([option])으로 기존 대기열을 덮어쓰면, 이전에 대기 중이던 스낵바들의 onClose 콜백이 호출되지 않고 폐기됩니다. 소비자가 onClose에 정리 로직을 의존하는 경우 예상치 못한 동작이 발생할 수 있습니다.

이 동작이 의도된 것이라면, 문서에 "immediate 모드에서 대기 중인 스낵바는 onClose 호출 없이 폐기된다"는 점을 명시하는 것이 좋겠습니다.

🔧 대기열 덮어쓰기 전 onClose 호출을 고려한 예시
         } else if (state === "dismissing") {
           setQueue([option]);
         } else {
+          // 덮어쓰기 전 대기 중인 스낵바의 onClose 호출 (선택적)
+          setQueue((prev) => {
+            prev.forEach((item) => item.onClose?.());
+            return [option];
+          });
-          setQueue([option]);
           setState("dismissing");
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-headless/snackbar/src/useSnackbar.ts` around lines 117 - 136,
In push (useSnackbar) when effectiveStrategy === "immediate" the code overwrites
the existing queue via setQueue([option]) which discards queued snackbars
without calling their onClose; before replacing the queue (both in the state ===
"dismissing" and the else branch) iterate the current queued items (the existing
queue variable) and call each item's onClose callback if present, then replace
the queue with [option]; this ensures queued snackbars' cleanup runs when they
are discarded by push, while leaving the rest of the logic (setCurrentSnackbar,
setState, etc.) unchanged.
🤖 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/react-headless/snackbar/src/useSnackbar.ts`:
- Around line 130-136: When queuing in "queued" mode multiple synchronous calls
can all see the stale closure value of `state` and each call will run `pop()`;
fix by using a functional state updater so the transition from
"inactive"→"active" is performed atomically and `pop()` only runs once: replace
the direct `if (state === "inactive") { pop(); setState("active"); }` with a
single `setState(prev => { if (prev === "inactive") { pop(); return "active"; }
return prev; })` (or use a dedicated ref/flag guarded in `useSnackbar`),
ensuring you call `push(option)` before invoking the updater and keep references
to `push`, `pop`, and `setState` intact.

---

Nitpick comments:
In `@packages/react-headless/snackbar/src/useSnackbar.ts`:
- Around line 117-136: In push (useSnackbar) when effectiveStrategy ===
"immediate" the code overwrites the existing queue via setQueue([option]) which
discards queued snackbars without calling their onClose; before replacing the
queue (both in the state === "dismissing" and the else branch) iterate the
current queued items (the existing queue variable) and call each item's onClose
callback if present, then replace the queue with [option]; this ensures queued
snackbars' cleanup runs when they are discarded by push, while leaving the rest
of the logic (setCurrentSnackbar, setState, etc.) unchanged.
🪄 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: 708af3e0-c669-4702-8187-63ef837a4408

📥 Commits

Reviewing files that changed from the base of the PR and between b9dacc7 and 79836a1.

📒 Files selected for processing (2)
  • packages/react-headless/snackbar/src/useSnackbar.test.tsx
  • packages/react-headless/snackbar/src/useSnackbar.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/react-headless/snackbar/src/useSnackbar.test.tsx

Comment thread packages/react-headless/snackbar/src/useSnackbar.ts Outdated
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)
examples/stackflow-spa/src/activities/ActivityHome.tsx (1)

231-241: "workaround" 패턴의 사용 목적에 대한 주석 추가를 권장합니다.

이 예제는 dismiss()setTimeout으로 새 스낵바를 생성하는 패턴을 보여주고 있습니다. 예제 코드로서 이 패턴이 언제, 왜 필요한지 설명하는 주석이 있으면 사용자 이해에 도움이 될 것 같습니다.

또한 setTimeout의 delay 인자를 명시적으로 0으로 지정하면 의도가 더 명확해집니다.

♻️ 제안
        {
+         // immediate strategy가 기본값일 때, 기존 스낵바를 즉시 닫고
+         // 새 스낵바를 표시하려는 경우의 workaround 패턴
          title: "Snackbar (dismiss+setTimeout workaround)",
          onClick: () => {
            snackbarAdapter.dismiss();
-           setTimeout(() => {
+           setTimeout(() => {
              snackbarAdapter.create({
                render: () => <Snackbar message="Workaround Snackbar" />,
              });
-           });
+           }, 0);
          },
        },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/stackflow-spa/src/activities/ActivityHome.tsx` around lines 231 -
241, Add an inline comment above the button handler that explains why the
"workaround" pattern is used: that calling snackbarAdapter.dismiss() immediately
followed by creating a new snackbar needs to defer creation to the next tick to
avoid race/DOM/state conflicts, and make the setTimeout explicit by passing 0 as
the delay; reference the handler's use of snackbarAdapter.dismiss(),
setTimeout(..., 0), snackbarAdapter.create({...}), and the Snackbar component so
reviewers can see the intent clearly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@examples/stackflow-spa/src/activities/ActivityHome.tsx`:
- Around line 231-241: Add an inline comment above the button handler that
explains why the "workaround" pattern is used: that calling
snackbarAdapter.dismiss() immediately followed by creating a new snackbar needs
to defer creation to the next tick to avoid race/DOM/state conflicts, and make
the setTimeout explicit by passing 0 as the delay; reference the handler's use
of snackbarAdapter.dismiss(), setTimeout(..., 0), snackbarAdapter.create({...}),
and the Snackbar component so reviewers can see the intent clearly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4df7638d-3b61-4187-a372-00f6d8d1ea66

📥 Commits

Reviewing files that changed from the base of the PR and between 79836a1 and cef117c.

📒 Files selected for processing (2)
  • docs/examples/react/snackbar/strategy.tsx
  • examples/stackflow-spa/src/activities/ActivityHome.tsx
✅ Files skipped from review due to trivial changes (1)
  • docs/examples/react/snackbar/strategy.tsx

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

Caution

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

⚠️ Outside diff range comments (1)
packages/react-headless/snackbar/src/useSnackbar.ts (1)

160-164: ⚠️ Potential issue | 🟠 Major

dismiss() 호출 시 onClose 콜백이 두 번 실행됩니다.

dismiss() 함수에서 invokeOnClose()를 직접 호출하고 (line 163), "dismissing" 상태의 effect에서도 invokeOnClose()를 호출합니다 (line 107). 사용자가 닫기 버튼을 클릭하면 onClose 콜백이 두 번 실행됩니다.

dismiss()에서는 상태 전환만 수행하고, onClose 호출은 effect에서 일관되게 처리하는 것이 좋습니다.

🐛 중복 호출 제거를 위한 수정 제안
       dismiss: () => {
         if (state === "active" || state === "persist") {
           setState("dismissing");
-          invokeOnClose();
         }
       },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-headless/snackbar/src/useSnackbar.ts` around lines 160 - 164,
The dismiss() implementation is directly calling invokeOnClose() and the effect
that watches state also calls invokeOnClose(), causing duplicate onClose
invocations; update dismiss() (in useSnackbar.ts) to only change state via
setState("dismissing") when state is "active" or "persist" and remove the direct
invokeOnClose() call, relying on the existing effect that observes state ===
"dismissing" to call invokeOnClose(); keep references to state, setState,
dismiss, and invokeOnClose so reviewers can locate the change.
🧹 Nitpick comments (1)
packages/react-headless/snackbar/src/useSnackbar.ts (1)

167-167: 의존성 배열에서 state 제거 검토

events.push는 functional updater로 state를 직접 참조하지 않지만, pause, resume, dismiss는 클로저에서 state를 읽습니다. state가 변경될 때마다 새로운 events 객체가 생성되어 하위 컴포넌트의 불필요한 리렌더링을 유발할 수 있습니다.

pause, resume, dismiss도 functional updater 패턴으로 변경하면 state 의존성을 제거할 수 있습니다.

♻️ functional updater 패턴 적용 예시
       pause: () => {
-        if (state === "active") {
-          if (pauseOnInteraction) {
-            setState("persist");
-          }
-        }
+        setState((prev) => {
+          if (prev === "active" && pauseOnInteraction) {
+            return "persist";
+          }
+          return prev;
+        });
       },
       resume: () => {
-        if (state === "persist") {
-          setState("active");
-        }
+        setState((prev) => (prev === "persist" ? "active" : prev));
       },
       dismiss: () => {
-        if (state === "active" || state === "persist") {
-          setState("dismissing");
-        }
+        setState((prev) => {
+          if (prev === "active" || prev === "persist") {
+            return "dismissing";
+          }
+          return prev;
+        });
       },

이 변경 후 의존성 배열에서 state를 제거할 수 있습니다.

🤖 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/react-headless/snackbar/src/useSnackbar.ts`:
- Around line 122-134: The updater passed to setState in useSnackbar (the
anonymous updater in lines around setState) must not perform side effects like
calling setCurrentSnackbar, setQueue or pop(); refactor the related state
(state, queue, currentSnackbar) into a single reducer or a single state object
and replace the multiple setters with a useReducer-based state machine (or an
atomic state setter) so transitions like "inactive" -> "active", enqueueing, and
dismissing are handled inside reducer logic (move the logic currently in the
setState updater and in pop() into reducer action handlers) and ensure
setCurrentSnackbar, setQueue and pop() are only called from effectful places if
needed after reducer state updates.

---

Outside diff comments:
In `@packages/react-headless/snackbar/src/useSnackbar.ts`:
- Around line 160-164: The dismiss() implementation is directly calling
invokeOnClose() and the effect that watches state also calls invokeOnClose(),
causing duplicate onClose invocations; update dismiss() (in useSnackbar.ts) to
only change state via setState("dismissing") when state is "active" or "persist"
and remove the direct invokeOnClose() call, relying on the existing effect that
observes state === "dismissing" to call invokeOnClose(); keep references to
state, setState, dismiss, and invokeOnClose so reviewers can locate the change.
🪄 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: 5dfa20f8-d755-401f-86e8-e3716ff6fcd8

📥 Commits

Reviewing files that changed from the base of the PR and between b1d913f and cfac493.

📒 Files selected for processing (1)
  • packages/react-headless/snackbar/src/useSnackbar.ts

Comment thread packages/react-headless/snackbar/src/useSnackbar.ts Outdated
te6-in and others added 13 commits April 23, 2026 18:17
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…vior

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

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

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

Fixes two real bugs that arose from mixing side-effects into setState
updaters and splitting onClose invocation across event handler and effect:

- StrictMode double-invocation of setState updaters caused pop() (which
  itself mutated queue via setQueue) to run twice, corrupting the queue
  on rapid synchronous pushes.
- dismiss() invoked onClose directly while the dismissing effect also
  invoked it after removeDelay, resulting in duplicate calls.

Consolidates state, queue, and currentSnackbar into a single reducer so
all transitions are pure. Timers and onClose now live only in the effect.
events memo drops its state dependency as a side benefit.
Adds a brief comment explaining the pattern and makes the setTimeout
delay explicit (0) so readers don't have to infer intent from an omitted
argument.
Pins the intended contract of onClose: it fires only for snackbars that
were actually presented (became currentSnackbar). Queued items cleared
by an immediate-strategy push never entered the display lifecycle, so
their onClose must not be invoked.
@te6-in te6-in force-pushed the feat/snackbar-immediate-strategy branch from a332ae9 to 4c5dde6 Compare April 23, 2026 09:18
@te6-in te6-in requested review from SeieunYoo and junghyeonsu April 23, 2026 12:28
style: {
position: "relative",
pointerEvents: "auto",
"--snackbar-remove-delay": `${currentSnackbar?.removeDelay ?? 0}ms`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

요기도 200 으로 맞출 필요는 없을까요?

Copy link
Copy Markdown
Contributor

@junghyeonsu junghyeonsu left a comment

Choose a reason for hiding this comment

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

changeset만 작성해주시면 될듯 굿

removeCurrentSnackbar();
}, removeDelay);
return () => clearTimeout(timeout);
const visibleDuration = currentSnackbar?.timeout ?? 5000;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

이 부분은 4000으로 되는거죠?

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.

3 participants