Skip to content

Commit ffa1cda

Browse files
authored
Merge pull request #16 from sjsjsj1246/codex/harden-promise-api
[-]: Promise API 계약 안정화
2 parents 056bddb + c68a06d commit ffa1cda

10 files changed

Lines changed: 80 additions & 19 deletions

File tree

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,12 @@ const App = () => {
8888
};
8989
```
9090

91-
`tutorial.open()` returns `Promise<{ reason: 'completed' | 'skipped' | 'closed' }>`:
91+
`tutorial.open()` returns `Promise<{ reason: 'completed' | 'skipped' | 'closed' | 'replaced' }>`:
9292

9393
- `completed`: the user finished the last step.
9494
- `skipped`: the user clicked the built-in `건너뛰기` button.
95-
- `closed`: the tutorial was closed externally, such as `tutorial.close()`, `Escape`, backdrop click, or opening a new tutorial while another promise is still pending.
95+
- `closed`: the tutorial was closed externally, such as `tutorial.close()`, `Escape`, or backdrop click.
96+
- `replaced`: a newer `tutorial.open()` call replaced a tutorial whose promise was still pending.
9697

9798
To start from a specific step, pass `startAt` to `tutorial.open()`:
9899

@@ -138,7 +139,7 @@ The info box automatically flips and clamps itself to stay inside the viewport w
138139
139140
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.
140141
141-
`options.onClose` still runs whenever the tutorial closes. Use the returned Promise when you need async flow control after the tutorial ends.
142+
`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.
142143
143144
Mount `<TutorialOverlay />` once near the root of your app, then trigger `tutorial.open({ steps, options, startAt })` from any event handler or effect.
144145

docs/plans/2026-03-24-project-restart.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- 완료: `codex/add-promise-api` 병합 완료
2222
- 완료: `codex/improve-accessibility` 병합 완료
2323
- 완료: `codex/add-progress-control-api` 병합 완료
24+
- 완료: `codex/harden-promise-api` 병합 완료
2425
- 반영된 내용:
2526
- `packages/main/test/setup.ts` 추가
2627
- `packages/main/jest.config.js` 정상화
@@ -45,10 +46,13 @@
4546
- `input` / `textarea` / `select` / `contenteditable` 포커스 중 단축키 무시 처리 추가
4647
- backdrop click close를 opt-in 동작으로 추가하고 highlight / info box 클릭과 구분
4748
- keyboard / overlay close 관련 README 및 docs 예제 업데이트
48-
- `tutorial.open()``Promise<{ reason: 'completed' | 'skipped' | 'closed' }>`를 반환하도록 확장
49-
- 마지막 step 완료, built-in 건너뛰기, 외부 close 경로별 resolve reason 구분 추가
50-
- 새 tutorial open 시 이전 pending promise를 `closed`로 정리하도록 보정
49+
- `tutorial.open()``Promise<{ reason: 'completed' | 'skipped' | 'closed' | 'replaced' }>`를 반환하도록 확장
50+
- 마지막 step 완료, built-in 건너뛰기, 외부 close 경로, replacement open 경로별 resolve reason 계약 고정
51+
- 새 tutorial open 시 이전 pending promise를 `replaced`로 정리하도록 보정
5152
- Promise API와 `onClose`의 역할을 분리하고 기존 callback 계약 유지
53+
- 이미 settle된 뒤의 중복 `tutorial.close()` 호출이 Promise 계약이나 `onClose` 호출 횟수를 깨뜨리지 않도록 보강
54+
- `Escape` / backdrop click / replacement open Promise resolve reason 테스트 추가
55+
- README 및 docs에 Promise resolve 정책과 `replaced` reason 명시
5256
- `tutorial.open()``startAt` 추가로 시작 step을 명시적으로 제어 가능
5357
- `tutorial.goTo(index)` 추가로 active tutorial의 step index를 외부에서 이동 가능
5458
- `tutorial.getState()` 추가로 open 여부, 현재 index, step 수, current step snapshot 조회 가능
@@ -88,6 +92,8 @@
8892

8993
첫 기능 확장 작업으로 권장했던 `codex/add-keyboard-and-close-controls`, `codex/add-highlight-padding-and-overlay-click-behavior`, `codex/add-promise-api`는 모두 완료 및 병합됐다.
9094

95+
Promise API 후속 안정화 작업으로 진행한 `codex/harden-promise-api`도 완료 및 병합됐다. 현재 Promise contract는 `completed`, `skipped`, `closed`, `replaced`를 구분하고, replacement open과 중복 close edge case까지 테스트로 고정된 상태다.
96+
9197
후속 접근성 안정화 작업으로 권장했던 `codex/improve-accessibility`도 완료 및 병합됐다. 현재 overlay는 dialog semantics와 open/close focus lifecycle까지는 안정화됐고, focus trap과 background inert는 다음 접근성 확장 후보로 남겨둔다.
9298

9399
첫 progress control 확장 후보였던 `codex/add-progress-control-api`도 완료 및 병합됐다. 이제 외부 제어는 `startAt`, `goTo(index)`, `getState()`까지 포함하는 수준으로 정리됐고, 다음 확장 후보는 richer state subscription이나 callback surface가 필요한지 여부를 별도 계획 문서에서 다시 판단하면 된다.
@@ -399,9 +405,10 @@ Expected:
399405
**Goal:** 튜토리얼 완료/취소 시점을 소비자가 await할 수 있도록 Promise 기반 API를 추가한다.
400406

401407
**Result:**
402-
- `tutorial.open()``Promise<{ reason: 'completed' | 'skipped' | 'closed' }>`를 반환하도록 변경
403-
- 마지막 step 완료 시 `completed`, built-in `건너뛰기` 버튼 경로에서 `skipped`, 외부 `tutorial.close()` / `Escape` / backdrop close / replacement open 경로에서 `closed` resolve 추가
408+
- `tutorial.open()``Promise<{ reason: 'completed' | 'skipped' | 'closed' | 'replaced' }>`를 반환하도록 변경
409+
- 마지막 step 완료 시 `completed`, built-in `건너뛰기` 버튼 경로에서 `skipped`, 외부 `tutorial.close()` / `Escape` / backdrop close 경로에서 `closed`, replacement open 경로에서 `replaced` resolve 추가
404410
- pending promise가 한 번만 settle되도록 중복 resolve 방지
405-
- 새 tutorial을 열면 기존 pending tutorial을 `closed`로 정리하고 기존 `options.onClose`도 계속 실행되도록 유지
411+
- 새 tutorial을 열면 기존 pending tutorial을 `replaced`로 정리하고 기존 `options.onClose`도 계속 실행되도록 유지
406412
- `packages/main/test/tutorial.test.tsx``packages/main/test/content.test.tsx`에 Promise API 회귀 테스트 추가
413+
- `packages/main/test/tutorial-overlay.test.tsx``Escape` / backdrop click close reason 테스트 추가
407414
- README 및 docs landing/tutorial/tutorial-overlay 문서에 async usage 예제와 reason 계약 반영

packages/document/src/pages/docs/index.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const App = () => {
5858

5959
Mount `<TutorialOverlay />` once in your app. Then call `tutorial.open({ steps, options })` from any button handler or effect.
6060

61-
`tutorial.open()` resolves with `{ reason: 'completed' | 'skipped' | 'closed' }` so you can await the end of the tutorial.
61+
`tutorial.open()` resolves with `{ reason: 'completed' | 'skipped' | 'closed' | 'replaced' }` so you can await the end of the tutorial.
6262

6363
Use `startAt` to open on a specific step, `tutorial.goTo(index)` to move within an active tutorial, and `tutorial.getState()` to read `{ open, index, stepCount, currentStep }` for external progress UI.
6464

packages/document/src/pages/docs/tutorial-overlay.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ function App() {
2424

2525
`TutorialOverlay` does not need props for the current public API. Configure behavior through `tutorial.open({ steps, options })`.
2626

27-
Because `tutorial.open()` returns a Promise, you can keep `<TutorialOverlay />` mounted once and await tutorial completion from any handler or effect that triggers the open call.
27+
Because `tutorial.open()` returns a Promise, you can keep `<TutorialOverlay />` 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.
2828

2929
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.
3030

packages/document/src/pages/docs/tutorial.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ function App() {
6262

6363
## Available methods
6464

65-
- `tutorial.open({ steps, options, startAt })`: opens a tutorial and starts at `startAt` when provided. `startAt` is clamped to the available step range. Returns `Promise<{ reason: 'completed' | 'skipped' | 'closed' }>` when that tutorial ends.
65+
- `tutorial.open({ steps, options, startAt })`: opens a tutorial and starts at `startAt` when provided. `startAt` is clamped to the available step range. Returns `Promise<{ reason: 'completed' | 'skipped' | 'closed' | 'replaced' }>` when that tutorial ends.
6666
- `tutorial.next()`: moves to the next step and closes the overlay after the last step.
6767
- `tutorial.prev()`: moves back one step when possible.
6868
- `tutorial.goTo(index)`: moves the active tutorial to a specific step. The index is clamped to the available range, and calling it while closed is a no-op.
@@ -74,7 +74,8 @@ function App() {
7474

7575
- `completed`: the last step was completed through `tutorial.next()` or the built-in `완료` button.
7676
- `skipped`: the built-in `건너뛰기` button was clicked.
77-
- `closed`: the tutorial was closed manually with `tutorial.close()`, `Escape`, backdrop click, or because a newer tutorial replaced a pending one.
77+
- `closed`: the tutorial was closed manually with `tutorial.close()`, `Escape`, or backdrop click.
78+
- `replaced`: a newer `tutorial.open()` call replaced a tutorial whose promise was still pending.
7879

7980
## Step fields
8081

@@ -114,7 +115,7 @@ console.log(state.currentStep?.title);
114115
- `labels`: overrides the built-in `prev`, `next`, `skip`, and `done` button labels.
115116
- `keyboardNavigation`: enables `Escape`, `ArrowLeft`, and `ArrowRight` shortcuts while the overlay is open. Defaults to `true`.
116117
- `closeOnOverlayClick`: closes the tutorial when the backdrop itself is clicked. Defaults to `false`.
117-
- `onClose`: runs when the tutorial is closed.
118+
- `onClose`: runs when the active tutorial is closed, including replacement by a newer `tutorial.open()` call.
118119
119120
Keyboard shortcuts are ignored while an `input`, `textarea`, `select`, or `contenteditable` element has focus.
120121
The info box automatically repositions itself to stay within the viewport when the target is close to an edge.

packages/main/src/core/store.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ function settlePendingTutorial(result: TutorialResult) {
4848
return;
4949
}
5050

51+
// Clear the resolver before invoking it so duplicate close paths stay a no-op.
5152
const resolve = pendingTutorialResolver;
5253
pendingTutorialResolver = null;
5354
resolve(result);
@@ -61,6 +62,10 @@ const reducer = (state: State, action: Action): State => {
6162
index: clampIndex(action.payload.index, action.payload.tutorial.steps.length),
6263
};
6364
case ActionType.CLOSE:
65+
if (!state.open && !hasPendingTutorialResult()) {
66+
return initialState;
67+
}
68+
6469
settlePendingTutorial({ reason: action.payload.reason });
6570
state.tutorial.options?.onClose?.();
6671
return initialState;

packages/main/src/core/tutorial.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import type { Step, Tutorial, TutorialProgressState, TutorialResult } from './ty
1010

1111
const open = (tutorial: Tutorial, otherState?: Omit<State, 'tutorial'>): Promise<TutorialResult> => {
1212
if (getStoreState().open || hasPendingTutorialResult()) {
13-
dispatch({ type: ActionType.CLOSE, payload: { reason: 'closed' } });
13+
dispatch({ type: ActionType.CLOSE, payload: { reason: 'replaced' } });
1414
}
1515

1616
const resultPromise = createPendingTutorialResult();

packages/main/src/core/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export interface Tutorial {
3737
startAt?: number;
3838
}
3939

40-
export type TutorialResultReason = 'completed' | 'skipped' | 'closed';
40+
export type TutorialResultReason = 'completed' | 'skipped' | 'closed' | 'replaced';
4141

4242
export interface TutorialResult {
4343
reason: TutorialResultReason;

packages/main/test/tutorial-overlay.test.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ function renderOverlay() {
1818
}
1919

2020
function openTutorial(options: Options = {}) {
21+
let resultPromise;
22+
2123
act(() => {
22-
tutorial.open({
24+
resultPromise = tutorial.open({
2325
steps: [
2426
{
2527
title: 'Step 1',
@@ -35,6 +37,8 @@ function openTutorial(options: Options = {}) {
3537
options,
3638
});
3739
});
40+
41+
return resultPromise;
3842
}
3943

4044
function createDomRect({ left, top, width, height }: { left: number; top: number; width: number; height: number }): DOMRect {
@@ -105,6 +109,16 @@ describe('TutorialOverlay', () => {
105109
expect(screen.queryByText('Step 1 content')).not.toBeInTheDocument();
106110
});
107111

112+
test('Escape resolves the tutorial promise with closed', async () => {
113+
renderOverlay();
114+
115+
const resultPromise = openTutorial();
116+
117+
fireEvent.keyDown(window, { key: 'Escape' });
118+
119+
await expect(resultPromise).resolves.toEqual({ reason: 'closed' });
120+
});
121+
108122
test('exposes the info box as a labeled dialog', () => {
109123
renderOverlay();
110124
openTutorial();
@@ -220,6 +234,16 @@ describe('TutorialOverlay', () => {
220234
expect(screen.queryByText('Step 1 content')).not.toBeInTheDocument();
221235
});
222236

237+
test('backdrop click resolves the tutorial promise with closed when enabled', async () => {
238+
renderOverlay();
239+
240+
const resultPromise = openTutorial({ closeOnOverlayClick: true });
241+
242+
fireEvent.click(screen.getByTestId('tutorial-overlay-backdrop'));
243+
244+
await expect(resultPromise).resolves.toEqual({ reason: 'closed' });
245+
});
246+
223247
test('does not close on backdrop click by default', () => {
224248
const onClose = jest.fn();
225249

packages/main/test/tutorial.test.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ describe('tutorial core API', () => {
247247
expect(onClose).toHaveBeenCalledTimes(1);
248248
});
249249

250-
test('opening a new tutorial resolves the previous pending promise with closed', async () => {
250+
test('opening a new tutorial resolves the previous pending promise with replaced', async () => {
251251
render(<StateProbe />);
252252

253253
let firstPromise;
@@ -268,7 +268,30 @@ describe('tutorial core API', () => {
268268
tutorial.close();
269269
});
270270

271-
await expect(firstPromise).resolves.toEqual({ reason: 'closed' });
271+
await expect(firstPromise).resolves.toEqual({ reason: 'replaced' });
272272
await expect(secondPromise).resolves.toEqual({ reason: 'closed' });
273273
});
274+
275+
test('repeated close calls after the tutorial settles do not re-run onClose or change the result', async () => {
276+
render(<StateProbe />);
277+
const onClose = jest.fn();
278+
279+
let resultPromise;
280+
281+
act(() => {
282+
resultPromise = tutorial.open({
283+
steps: [{ title: 'Only step', targetIds: ['only-target'] }],
284+
options: { onClose },
285+
});
286+
});
287+
288+
act(() => {
289+
tutorial.next();
290+
tutorial.close();
291+
tutorial.close();
292+
});
293+
294+
await expect(resultPromise).resolves.toEqual({ reason: 'completed' });
295+
expect(onClose).toHaveBeenCalledTimes(1);
296+
});
274297
});

0 commit comments

Comments
 (0)