From 41e2264fbb8aaf1333bd69a717b70fd4af833fa3 Mon Sep 17 00:00:00 2001 From: sjsjsj1246 Date: Wed, 25 Mar 2026 15:38:35 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[-]:=20Promise=20API=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=20=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++--- docs/plans/2026-03-24-project-restart.md | 19 +++++++++----- packages/document/src/pages/docs/index.mdx | 2 +- .../src/pages/docs/tutorial-overlay.mdx | 2 +- packages/document/src/pages/docs/tutorial.mdx | 7 ++--- packages/main/src/core/store.ts | 5 ++++ packages/main/src/core/tutorial.ts | 2 +- packages/main/src/core/types.ts | 2 +- packages/main/test/tutorial-overlay.test.tsx | 26 ++++++++++++++++++- packages/main/test/tutorial.test.tsx | 25 +++++++++++++++++- 10 files changed, 79 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ee00dc2..8d34699 100644 --- a/README.md +++ b/README.md @@ -88,11 +88,12 @@ const App = () => { }; ``` -`tutorial.open()` returns `Promise<{ reason: 'completed' | 'skipped' | 'closed' }>`: +`tutorial.open()` returns `Promise<{ reason: 'completed' | 'skipped' | 'closed' | 'replaced' }>`: - `completed`: the user finished the last step. - `skipped`: the user clicked the built-in `건너뛰기` button. -- `closed`: the tutorial was closed externally, such as `tutorial.close()`, `Escape`, backdrop click, or opening a new tutorial while another promise is still pending. +- `closed`: the tutorial was closed externally, such as `tutorial.close()`, `Escape`, or backdrop click. +- `replaced`: a newer `tutorial.open()` call replaced a tutorial whose promise was still pending. `content` is rendered as a plain string. HTML markup in the string is not interpreted. @@ -116,7 +117,7 @@ The info box automatically flips and clamps itself to stay inside the viewport w For accessibility, the info box is exposed as a labeled `dialog`. When the tutorial opens, focus moves into the info box controls, and when it closes, focus returns to the element that was active before open. The library does not currently trap focus inside the overlay. -`options.onClose` still runs whenever the tutorial closes. Use the returned Promise when you need async flow control after the tutorial ends. +`options.onClose` still runs whenever the active tutorial closes, including replacement by a newer `tutorial.open()` call. Use the returned Promise when you need async flow control after the tutorial ends. Mount `` once near the root of your app, then trigger `tutorial.open({ steps, options })` from any event handler or effect. diff --git a/docs/plans/2026-03-24-project-restart.md b/docs/plans/2026-03-24-project-restart.md index 8efdc66..0589c79 100644 --- a/docs/plans/2026-03-24-project-restart.md +++ b/docs/plans/2026-03-24-project-restart.md @@ -20,6 +20,7 @@ - 완료: `codex/add-highlight-padding-and-overlay-click-behavior` 병합 완료 - 완료: `codex/add-promise-api` 병합 완료 - 완료: `codex/improve-accessibility` 병합 완료 +- 완료: `codex/harden-promise-api` 병합 완료 - 반영된 내용: - `packages/main/test/setup.ts` 추가 - `packages/main/jest.config.js` 정상화 @@ -44,10 +45,13 @@ - `input` / `textarea` / `select` / `contenteditable` 포커스 중 단축키 무시 처리 추가 - backdrop click close를 opt-in 동작으로 추가하고 highlight / info box 클릭과 구분 - keyboard / overlay close 관련 README 및 docs 예제 업데이트 - - `tutorial.open()`이 `Promise<{ reason: 'completed' | 'skipped' | 'closed' }>`를 반환하도록 확장 - - 마지막 step 완료, built-in 건너뛰기, 외부 close 경로별 resolve reason 구분 추가 - - 새 tutorial open 시 이전 pending promise를 `closed`로 정리하도록 보정 + - `tutorial.open()`이 `Promise<{ reason: 'completed' | 'skipped' | 'closed' | 'replaced' }>`를 반환하도록 확장 + - 마지막 step 완료, built-in 건너뛰기, 외부 close 경로, replacement open 경로별 resolve reason 계약 고정 + - 새 tutorial open 시 이전 pending promise를 `replaced`로 정리하도록 보정 - Promise API와 `onClose`의 역할을 분리하고 기존 callback 계약 유지 + - 이미 settle된 뒤의 중복 `tutorial.close()` 호출이 Promise 계약이나 `onClose` 호출 횟수를 깨뜨리지 않도록 보강 + - `Escape` / backdrop click / replacement open Promise resolve reason 테스트 추가 + - README 및 docs에 Promise resolve 정책과 `replaced` reason 명시 - README 및 docs에 async / await 사용 예제 추가 - info box를 labeled `dialog`로 노출하고 title/content를 `aria-labelledby` / `aria-describedby`로 연결 - overlay open 시 info box의 첫 built-in control로 focus 이동 추가 @@ -81,6 +85,8 @@ 첫 기능 확장 작업으로 권장했던 `codex/add-keyboard-and-close-controls`, `codex/add-highlight-padding-and-overlay-click-behavior`, `codex/add-promise-api`는 모두 완료 및 병합됐다. +Promise API 후속 안정화 작업으로 진행한 `codex/harden-promise-api`도 완료 및 병합됐다. 현재 Promise contract는 `completed`, `skipped`, `closed`, `replaced`를 구분하고, replacement open과 중복 close edge case까지 테스트로 고정된 상태다. + 후속 접근성 안정화 작업으로 권장했던 `codex/improve-accessibility`도 완료 및 병합됐다. 현재 overlay는 dialog semantics와 open/close focus lifecycle까지는 안정화됐고, focus trap과 background inert는 다음 접근성 확장 후보로 남겨둔다. 이 문서는 현재까지 반영된 후속 기능 이력을 남기는 참고 문서로 유지한다. 다음 신규 기능 후보는 별도 계획 문서에서 다시 우선순위를 잡는 편이 맞다. @@ -390,9 +396,10 @@ Expected: **Goal:** 튜토리얼 완료/취소 시점을 소비자가 await할 수 있도록 Promise 기반 API를 추가한다. **Result:** -- `tutorial.open()`이 `Promise<{ reason: 'completed' | 'skipped' | 'closed' }>`를 반환하도록 변경 -- 마지막 step 완료 시 `completed`, built-in `건너뛰기` 버튼 경로에서 `skipped`, 외부 `tutorial.close()` / `Escape` / backdrop close / replacement open 경로에서 `closed` resolve 추가 +- `tutorial.open()`이 `Promise<{ reason: 'completed' | 'skipped' | 'closed' | 'replaced' }>`를 반환하도록 변경 +- 마지막 step 완료 시 `completed`, built-in `건너뛰기` 버튼 경로에서 `skipped`, 외부 `tutorial.close()` / `Escape` / backdrop close 경로에서 `closed`, replacement open 경로에서 `replaced` resolve 추가 - pending promise가 한 번만 settle되도록 중복 resolve 방지 -- 새 tutorial을 열면 기존 pending tutorial을 `closed`로 정리하고 기존 `options.onClose`도 계속 실행되도록 유지 +- 새 tutorial을 열면 기존 pending tutorial을 `replaced`로 정리하고 기존 `options.onClose`도 계속 실행되도록 유지 - `packages/main/test/tutorial.test.tsx` 및 `packages/main/test/content.test.tsx`에 Promise API 회귀 테스트 추가 +- `packages/main/test/tutorial-overlay.test.tsx`에 `Escape` / backdrop click close reason 테스트 추가 - README 및 docs landing/tutorial/tutorial-overlay 문서에 async usage 예제와 reason 계약 반영 diff --git a/packages/document/src/pages/docs/index.mdx b/packages/document/src/pages/docs/index.mdx index 47ecd7a..c3df040 100644 --- a/packages/document/src/pages/docs/index.mdx +++ b/packages/document/src/pages/docs/index.mdx @@ -58,6 +58,6 @@ const App = () => { Mount `` once in your app. Then call `tutorial.open({ steps, options })` from any button handler or effect. -`tutorial.open()` resolves with `{ reason: 'completed' | 'skipped' | 'closed' }` so you can await the end of the tutorial. +`tutorial.open()` resolves with `{ reason: 'completed' | 'skipped' | 'closed' | 'replaced' }` so you can await the end of the tutorial. `content` accepts a string. HTML inside that string is shown as text rather than injected into the page. diff --git a/packages/document/src/pages/docs/tutorial-overlay.mdx b/packages/document/src/pages/docs/tutorial-overlay.mdx index 059aaf2..2454169 100644 --- a/packages/document/src/pages/docs/tutorial-overlay.mdx +++ b/packages/document/src/pages/docs/tutorial-overlay.mdx @@ -24,7 +24,7 @@ function App() { `TutorialOverlay` does not need props for the current public API. Configure behavior through `tutorial.open({ steps, options })`. -Because `tutorial.open()` returns a Promise, you can keep `` mounted once and await tutorial completion from any handler or effect that triggers the open call. +Because `tutorial.open()` returns a Promise, you can keep `` mounted once and await tutorial completion from any handler or effect that triggers the open call. The Promise resolves with `completed`, `skipped`, `closed`, or `replaced` depending on how that run ended. The highlight frame uses `options.highLightPadding` to expand around the target. If you do not provide it, the overlay uses an `8px` padding by default. diff --git a/packages/document/src/pages/docs/tutorial.mdx b/packages/document/src/pages/docs/tutorial.mdx index 3ea025e..bc83336 100644 --- a/packages/document/src/pages/docs/tutorial.mdx +++ b/packages/document/src/pages/docs/tutorial.mdx @@ -62,7 +62,7 @@ function App() { ## Available methods -- `tutorial.open({ steps, options })`: opens a tutorial and starts at the first step by default. Returns `Promise<{ reason: 'completed' | 'skipped' | 'closed' }>` when that tutorial ends. +- `tutorial.open({ steps, options })`: opens a tutorial and starts at the first step by default. Returns `Promise<{ reason: 'completed' | 'skipped' | 'closed' | 'replaced' }>` when that tutorial ends. - `tutorial.next()`: moves to the next step and closes the overlay after the last step. - `tutorial.prev()`: moves back one step when possible. - `tutorial.close()`: closes the overlay and resolves the pending `tutorial.open()` promise with `reason: 'closed'`. @@ -72,7 +72,8 @@ function App() { - `completed`: the last step was completed through `tutorial.next()` or the built-in `완료` button. - `skipped`: the built-in `건너뛰기` button was clicked. -- `closed`: the tutorial was closed manually with `tutorial.close()`, `Escape`, backdrop click, or because a newer tutorial replaced a pending one. +- `closed`: the tutorial was closed manually with `tutorial.close()`, `Escape`, or backdrop click. +- `replaced`: a newer `tutorial.open()` call replaced a tutorial whose promise was still pending. ## Step fields @@ -96,7 +97,7 @@ function App() { - `labels`: overrides the built-in `prev`, `next`, `skip`, and `done` button labels. - `keyboardNavigation`: enables `Escape`, `ArrowLeft`, and `ArrowRight` shortcuts while the overlay is open. Defaults to `true`. - `closeOnOverlayClick`: closes the tutorial when the backdrop itself is clicked. Defaults to `false`. -- `onClose`: runs when the tutorial is closed. +- `onClose`: runs when the active tutorial is closed, including replacement by a newer `tutorial.open()` call. Keyboard shortcuts are ignored while an `input`, `textarea`, `select`, or `contenteditable` element has focus. The info box automatically repositions itself to stay within the viewport when the target is close to an edge. diff --git a/packages/main/src/core/store.ts b/packages/main/src/core/store.ts index 4b91a56..ee393a9 100644 --- a/packages/main/src/core/store.ts +++ b/packages/main/src/core/store.ts @@ -38,6 +38,7 @@ function settlePendingTutorial(result: TutorialResult) { return; } + // Clear the resolver before invoking it so duplicate close paths stay a no-op. const resolve = pendingTutorialResolver; pendingTutorialResolver = null; resolve(result); @@ -48,6 +49,10 @@ const reducer = (state: State, action: Action): State => { case ActionType.OPEN: return action.payload; case ActionType.CLOSE: + if (!state.open && !hasPendingTutorialResult()) { + return initialState; + } + settlePendingTutorial({ reason: action.payload.reason }); state.tutorial.options?.onClose?.(); return initialState; diff --git a/packages/main/src/core/tutorial.ts b/packages/main/src/core/tutorial.ts index f489567..7822694 100644 --- a/packages/main/src/core/tutorial.ts +++ b/packages/main/src/core/tutorial.ts @@ -10,7 +10,7 @@ import type { Step, Tutorial, TutorialResult } from './types'; const open = (tutorial: Tutorial, otherState?: Omit): Promise => { if (getState().open || hasPendingTutorialResult()) { - dispatch({ type: ActionType.CLOSE, payload: { reason: 'closed' } }); + dispatch({ type: ActionType.CLOSE, payload: { reason: 'replaced' } }); } const resultPromise = createPendingTutorialResult(); diff --git a/packages/main/src/core/types.ts b/packages/main/src/core/types.ts index e3198d8..bebbd70 100644 --- a/packages/main/src/core/types.ts +++ b/packages/main/src/core/types.ts @@ -36,7 +36,7 @@ export interface Tutorial { options?: Options; } -export type TutorialResultReason = 'completed' | 'skipped' | 'closed'; +export type TutorialResultReason = 'completed' | 'skipped' | 'closed' | 'replaced'; export interface TutorialResult { reason: TutorialResultReason; diff --git a/packages/main/test/tutorial-overlay.test.tsx b/packages/main/test/tutorial-overlay.test.tsx index 928a5ee..b0c7d38 100644 --- a/packages/main/test/tutorial-overlay.test.tsx +++ b/packages/main/test/tutorial-overlay.test.tsx @@ -18,8 +18,10 @@ function renderOverlay() { } function openTutorial(options: Options = {}) { + let resultPromise; + act(() => { - tutorial.open({ + resultPromise = tutorial.open({ steps: [ { title: 'Step 1', @@ -35,6 +37,8 @@ function openTutorial(options: Options = {}) { options, }); }); + + return resultPromise; } function createDomRect({ left, top, width, height }: { left: number; top: number; width: number; height: number }): DOMRect { @@ -105,6 +109,16 @@ describe('TutorialOverlay', () => { expect(screen.queryByText('Step 1 content')).not.toBeInTheDocument(); }); + test('Escape resolves the tutorial promise with closed', async () => { + renderOverlay(); + + const resultPromise = openTutorial(); + + fireEvent.keyDown(window, { key: 'Escape' }); + + await expect(resultPromise).resolves.toEqual({ reason: 'closed' }); + }); + test('exposes the info box as a labeled dialog', () => { renderOverlay(); openTutorial(); @@ -220,6 +234,16 @@ describe('TutorialOverlay', () => { expect(screen.queryByText('Step 1 content')).not.toBeInTheDocument(); }); + test('backdrop click resolves the tutorial promise with closed when enabled', async () => { + renderOverlay(); + + const resultPromise = openTutorial({ closeOnOverlayClick: true }); + + fireEvent.click(screen.getByTestId('tutorial-overlay-backdrop')); + + await expect(resultPromise).resolves.toEqual({ reason: 'closed' }); + }); + test('does not close on backdrop click by default', () => { const onClose = jest.fn(); diff --git a/packages/main/test/tutorial.test.tsx b/packages/main/test/tutorial.test.tsx index 1cc9dcb..7ffe0b5 100644 --- a/packages/main/test/tutorial.test.tsx +++ b/packages/main/test/tutorial.test.tsx @@ -155,7 +155,30 @@ describe('tutorial core API', () => { tutorial.close(); }); - await expect(firstPromise).resolves.toEqual({ reason: 'closed' }); + await expect(firstPromise).resolves.toEqual({ reason: 'replaced' }); await expect(secondPromise).resolves.toEqual({ reason: 'closed' }); }); + + test('repeated close calls after the tutorial settles do not re-run onClose or change the result', async () => { + render(); + const onClose = jest.fn(); + + let resultPromise; + + act(() => { + resultPromise = tutorial.open({ + steps: [{ title: 'Only step', targetIds: ['only-target'] }], + options: { onClose }, + }); + }); + + act(() => { + tutorial.next(); + tutorial.close(); + tutorial.close(); + }); + + await expect(resultPromise).resolves.toEqual({ reason: 'completed' }); + expect(onClose).toHaveBeenCalledTimes(1); + }); }); From a956e28a44f5c3edffa19fc30c5534c540c99813 Mon Sep 17 00:00:00 2001 From: sjsjsj1246 Date: Wed, 25 Mar 2026 15:39:20 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[-]:=20Promise=20API=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=AC=B8=EA=B5=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/main/test/tutorial.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/main/test/tutorial.test.tsx b/packages/main/test/tutorial.test.tsx index 7ffe0b5..4308829 100644 --- a/packages/main/test/tutorial.test.tsx +++ b/packages/main/test/tutorial.test.tsx @@ -134,7 +134,7 @@ describe('tutorial core API', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - test('opening a new tutorial resolves the previous pending promise with closed', async () => { + test('opening a new tutorial resolves the previous pending promise with replaced', async () => { render(); let firstPromise;