Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9c5342e
과제 제출을 위한 빈 커밋 날리기
parksubeom Nov 15, 2025
d107b5e
feat(utils): 'shallowEquals' 및 'deepEquals' 유틸리티 구현
parksubeom Nov 16, 2025
e4175f7
fix(core/elements): 텍스트 노드 정규화 및 컴포넌트 자식 처리 수정
parksubeom Nov 16, 2025
6696628
feat(core/dom): 'setDomProps' 및 DOM 유틸리티 구현
parksubeom Nov 16, 2025
d4ead0e
fix(core): 'dom' prop 핸들링 및 'reconciler' 앵커 로직 수정
parksubeom Nov 16, 2025
9797801
feat(dynamic): 기본 Hook 시스템 및 Reconciliation 루프 구현 완료
parksubeom Nov 16, 2025
1842b6b
feat(core): 동적 기능 파이프라인(useState, useEffect, Reconciliation) 구현 완료
parksubeom Nov 16, 2025
5d5ebdc
feat(core): useState 및 Keyed Reconciliation 구현 완료
parksubeom Nov 17, 2025
e9bb302
fix(core): useState 독립실행 수정 중
parksubeom Nov 17, 2025
b276d77
fix(core): useState 독립실행 수정 완료 및 basic 테스트 모두 구현
parksubeom Nov 17, 2025
90e2b3d
feat(advanced): 심화 Hooks(useRef, useMemo) 및 최적화 HOC(memo) 구현
parksubeom Nov 17, 2025
1cd5094
fix : pnpm dev 화면 안나오는 문제 해결 중
parksubeom Nov 17, 2025
62fca22
fix(react): useMemo 초기값 미설정으로 인한 런타임 오류 수정
parksubeom Nov 17, 2025
3224295
fix : 왜 자꾸 로딩중인가? 왜 자꾸 스켈레톤인가? 왜 데이터를 안받아오는가? 리렌더링안되는가?
parksubeom Nov 17, 2025
67fd67e
feat(final): 심화 Hooks 및 최종 런타임 안정화 완료
parksubeom Nov 18, 2025
8c7a805
refactor : 불필요한 콘솔 제거
parksubeom Nov 18, 2025
a11b1b9
fix: 중첩 컴포넌트 훅 상태 보존 및 App 초기 렌더 보강
parksubeom Nov 18, 2025
908b4c9
Merge branch 'main' of https://github.com/parksubeom/front_7th_chapte…
parksubeom Nov 18, 2025
3c4f98c
배포준비 및 세팅
parksubeom Nov 18, 2025
c0bc541
chore : 불필요한 콘솔로그 제거
parksubeom Nov 18, 2025
367d634
fix(react-dom): option selected 상태가 URL/스토어 값과 어긋나는 문제 수정
parksubeom Nov 19, 2025
84aba95
docs : 미니 리액트 동작개요와 트러블슈팅 정리
parksubeom Nov 19, 2025
055306b
refactor(hooks): useRef 기반의useMemo 리팩토링, useRef 훅에서 useState 의존성 제거
parksubeom Nov 20, 2025
209a61a
refactor(core): 순환 참조 해결을 위한 이펙트 스케줄링 구조 개선
parksubeom Nov 20, 2025
05cd0e2
fix(hooks): useRef 훅 순서 불일치 방어 코드 및 경고 메시지 추가
parksubeom Nov 20, 2025
9493176
docs(core): reconciler 주요 함수에 상세 JSDoc 주석 추가
parksubeom Nov 20, 2025
54fb919
Readme
parksubeom Dec 1, 2025
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
57 changes: 57 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Deploy to GitHub Pages

on:
push:
branches:
- main

workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: "pages"
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build
run: pnpm run build

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: packages/app/dist

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
348 changes: 234 additions & 114 deletions README.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions docs/04-advanced-hooks-hoc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## Advanced Hooks & HOC Notes

### useRef
- 구현: `useState`의 이니셜라이저로 `{ current: initialValue }` 객체를 한 번만 만들고 그대로 반환했습니다.
- 이유: 훅이 다시 호출돼도 동일한 ref 객체가 유지되어야 하므로 상태 훅을 이용해 “한 번 생성, 계속 재사용” 패턴을 강제했습니다.

### useMemo / useCallback
- 구현: `useMemo`는 `useRef`에 `{ deps, value }` 캐시를 저장하고, 기본 비교 함수(기본 `shallowEquals`)가 바뀌었다고 판단할 때만 `factory`를 다시 실행합니다. `useCallback`은 `useMemo(() => callback, deps)`로 단순 위임했습니다.
- 이유: 의존성 비교 로직을 하나로 통합해 재사용성을 높였습니다. `useCallback`을 `useMemo`에 위임하면 메모이제이션과 의존성 비교 방식이 항상 일관되게 유지됩니다.

### useDeepMemo
- 구현: `useMemo`에 `deepEquals`를 주입한 thin wrapper입니다.
- 이유: 복잡한 객체/배열 비교가 필요한 경우를 위해 별도 훅을 제공하면서도 기본 구현을 재사용해 코드 중복을 없앴습니다.

### useAutoCallback
- 구현: 최신 함수를 `useRef`에 저장하고, 의존성 없는 `useCallback`으로 안정적인 래퍼를 반환했습니다. 래퍼는 항상 `ref.current`를 호출합니다.
- 이유: 상태나 props를 캡처하지 않고도 최신 구현을 실행하고, 동시에 콜백 참조가 변하지 않도록 하기 위한 전형적인 패턴입니다.

### memo / deepMemo HOC
- 구현: `memo`는 실제로 훅을 사용하지 않고, 컴포넌트 타입에 `__memoConfig` 메타데이터를 붙입니다. `reconciler`가 업데이트 시 이 메타데이터를 읽어 이전 props와 `equals`로 비교한 뒤, 같다면 기존 `Instance`를 그대로 반환해 하위 트리를 건드리지 않습니다. `deepMemo`는 동일한 경로로 `deepEquals`를 주입한 얇은 래퍼입니다.
- 이유: 훅 기반 캐싱은 컴포넌트 중첩 시 경로 충돌 위험이 있어 렌더러 단계에서 직접 최적화하는 편이 안전했습니다. `Instance`가 이미 자식 `VNode`와 DOM 참조를 갖고 있으므로, 비교만 통과하면 전체 렌더 과정을 건너뛸 수 있습니다.

### 테스트 전략
- 심화 훅(`advanced.hooks.test.tsx`)과 HOC(`advanced.hoc.test.tsx`) 스펙을 그대로 만족하는지 `pnpm -F @hanghae-plus/react test ...`로 개별 실행합니다.

108 changes: 108 additions & 0 deletions docs/mini-react-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
## Mini-React 동작 개요

### 1. 진입점과 초기화
- `packages/react/src/core/setup.ts`의 `setup(rootNode, container)`가 MiniReact 앱의 진입점.
- 수행 순서
1. `context.root.node`/`container`를 등록하고
2. `hooks.ts`의 `setRenderTrigger(enqueueRender)`로 훅이 상태 변경 시 렌더를 요청할 수 있게 함
3. 최초 `enqueueRender()`를 호출해 첫 렌더링 실행

### 2. 렌더 사이클
- `render.ts`
1. `resetHookContext()`로 훅 커서/방문 정보 초기화
2. `reconcile(container, prevInstance, nextVNode, path, anchor)` 실행
3. 결과 인스턴스를 `context.root.instance`에 저장
4. `cleanupUnusedHooks()`로 사용되지 않은 훅 정리
5. `enqueueEffects()`로 대기 중인 effect 실행 예약
- `enqueueRender = withEnqueue(render)` : microtask 큐에 render를 넣어 동시 렌더를 방지

### 3. 리컨실리에이션
- `reconciler.ts`
- `reconcile(parentDom, instance, node, path, anchor)`
- `TEXT/HOST/COMPONENT` 타입에 따라 `mount*` / `update*` 로 분기
- 호스트/텍스트는 `dom.ts`의 `setDomProps`, `updateDomProps`, `insertInstance` 등을 통해 실제 DOM을 조작
- 컴포넌트는 `enterComponent`/`exitComponent`로 컨텍스트 설정 후 `node.type(props)` 실행
- memoized component 지원: `memoConfig` 비교 후 필요 시 스킵

### 4. DOM 업데이트
- `dom.ts`
- prop 삭제/추가/수정, 이벤트 바인딩 등 세분화된 단계로 처리
- `isProperty` 리스트(`checked`,`value`,`selected` 등)는 DOM 프로퍼티로 직접 써야 하는 항목
- `getDomNodes`, `insertInstance`, `removeInstance`로 children DOM을 수집/조작

### 5. 라이프사이클과 훅
- `context.ts`
- 현재 컴포넌트 경로, 훅 커서, 훅 상태 맵(`context.hooks.state`)을 추적
- `hooks.ts`
- `useState(initial)`
- `context.hooks.currentPath/cursor` 기반으로 상태 훅을 찾아 값 반환
- `setState` 호출 시 `triggerRender()`로 리렌더 요청
- `useEffect(effect, deps)`
- deps 비교 후 실행 대상이면 `context.effects.queue`에 `{path, cursor}` push
- 이후 `enqueueEffects()` → `flushEffects()`가 호출되면
1. cleanup이 있으면 먼저 실행
2. effect 실행 후 리턴값을 cleanup으로 저장
- 렌더마다 `context.effects.queue`를 비우고 필요 시 `cleanupUnusedHooks`
- `useRef(initial)`
- `{ current }` 객체를 유지, 값 변경이 리렌더를 일으키지 않음
- `setRenderTrigger(fn)`
- hooks가 사용할 render trigger를 setup 단계에서 주입
- `cleanupEffects(path)` / `cleanupUnusedHooks()`
- 방문되지 않은 컴포넌트의 effect cleanup과 state 제거

### 6. 라이프사이클 타이밍
| 단계 | 주요 함수 | 설명 |
| --- | --- | --- |
| 초기화 | `setup` | 컨텍스트/트리거 설정 후 첫 렌더 요청 |
| 렌더 시작 | `render` | 훅 컨텍스트 reset, reconcile 호출 |
| 리컨실리에이션 | `reconcile` | 컴포넌트/DOM를 실제 상태에 맞게 생성·갱신 |
| 마무리 | `cleanupUnusedHooks` | 방문하지 않은 컴포넌트 훅 정리 |
| Effect 실행 | `enqueueEffects` → `flushEffects` | 렌더 완료 후 비동기로 effect/cleanup 실행 |
| 상태 업데이트 | `useState.setState` | 값 변경 → `triggerRender()` → 렌더 큐 등록 |

### 7. Router & App과의 연결
- `packages/app/src/main.jsx`에서 `router.start()` 호출 후 `<App />` 렌더
- `App` → 각 `Page` → MiniReact 컴포넌트를 구성하며, 상태 변경은 훅과 router query 업데이트로 이어짐

---

## 훅 & 라이프사이클 상세 요약

### useState
```ts
const [state, setState] = useState(initial);
```
- `hooks.state[path][cursor]`에 `{ kind: "state", value }` 저장
- `setState`가 불린 순간:
1. 이전 값과 `Object.is` 비교 후 변경 시에만 진행
2. 새 값을 저장하고 `triggerRender()` 호출
- 렌더링 중 호출 순서를 `context.hooks.cursor`가 관리

### useEffect
```ts
useEffect(effectFn, deps?);
```
- deps가 없거나 변경되면 `{ path, cursor }`를 effect 큐에 push
- 렌더 종료 후 `enqueueEffects()` → `flushEffects()`
- 기존 cleanup 실행 → effect 실행 → cleanup 저장
- 에러는 콘솔에 로그만 남기고 렌더 흐름은 유지

### useRef
```ts
const ref = useRef(initialValue);
```
- `{ current }` 객체를 반환, 값 변경이 리렌더를 일으키지 않음
- DOM reference나 mutable value 저장용

### cleanupUnusedHooks
- 렌더 흐름에서 방문하지 않은 컴포넌트 path에 대해
- effect cleanup 실행
- hooks state/cursor 삭제

### render trigger 주입
- 훅 모듈은 의존성 문제를 피하기 위해 직접 `render`를 import하지 않고, setup 단계에서 `setRenderTrigger(enqueueRender)`로 주입받아 사용

---

이 문서는 MiniReact가 상태 변화를 감지하고 DOM을 업데이트하는 전 과정을 개략적으로 정리한 것이며, 각 세부 함수의 구현은 `packages/react/src/core`와 `packages/react/src/hooks` 이하에서 확인할 수 있습니다.

72 changes: 72 additions & 0 deletions docs/troubleshooting-select-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
## `<select>` 기본값이 URL/스토어와 다르게 표시된 이슈

### 0. TL;DR
- **증상**: 쿼리/스토어에는 `limit=20`, `sort=price_asc`가 들어 있는데, 화면에는 `limit=100`, `sort=name_asc`로 표시됨.
- **원인**: MiniReact가 `<option selected={...}>`를 DOM 프로퍼티가 아닌 attribute로만 업데이트했고, 브라우저는 attribute가 존재한다는 이유로 해당 옵션을 계속 선택 상태로 유지함.
- **해결**: `core/dom.ts`에서 `selected`를 DOM 프로퍼티로 다루도록 수정.

---

### 1. 증상 상세
| 항목 | 내용 |
| --- | --- |
| UI 표시 | 첫 렌더에서 항상 limit=100, sort=name_asc가 선택됨 |
| 실제 상태 | `router.query.limit = "20"`, `router.query.sort = "price_asc"` 등 정상 값 |
| 재현 조건 | 새로고침, 다른 필터 조합 등 모든 경우; SearchBar props 로그에서는 정상값 표시 |
| 영향 범위 | `<SearchBar>` 뿐 아니라 MiniReact 기반 모든 `<select>` 요소에 잠재적 영향 |

---

### 2. 추적 과정
1. **컴포넌트 로그 확인**
- HomePage & SearchBar에서 `limit`, `sort`를 출력해 보니 목표 값(예: 20/price_asc)이 그대로 내려오고 있었음.

2. **DOM 스냅샷 확인**
- DevTools에서 `<option>` 항목을 보면 `selected="false"`처럼 문자열 attribute가 남아 있었음.
- HTML 사양상 attribute가 존재하는 한 “선택된” 상태로 인식됨.

3. **렌더러 코드 검토**
- `core/dom.ts`에서 `isProperty`에 `selected`가 없어, `setAttribute("selected", false)`처럼 동작하는 것을 확인.
- React는 `<select value>`를 우선 적용하거나 `option.selected = …`로 동작하지만, MiniReact는 그런 로직이 없음.

4. **재현 테스트**
- `<option selected>`를 여러 번 토글해도 브라우저가 선택 상태를 유지하거나 초기화하지 않는 것을 확인.
- 컴포넌트 로직이 아니라 렌더러가 의심된다는 확신을 얻음.

---

### 3. 원인 요약
| 원인 | 설명 |
| --- | --- |
| Attribute vs Property | DOM에서 `selected`는 프로퍼티로 다뤄야 하는데 attribute만 바꾸고 있었음. |
| 렌더 타이밍 | MiniReact가 `<select>`보다 `<option>`을 먼저 삽입했고, props가 순차 적용돼 브라우저가 초기 선택을 고정함. |
| 테스트 사각지대 | 기존 유닛테스트는 DOM 레벨의 선택 상태를 검증하지 않아 누락됨. |

---

### 4. 수정 내용
| 파일 | 변경점 |
| --- | --- |
| `packages/react/src/core/dom.ts` | `isProperty` 목록에 `"selected"` 추가. 이제 `(dom as any).selected = value` 형태로 프로퍼티를 갱신. |
| (기존) `SearchBar.jsx` 등 | 컴포넌트 로직은 수정 없이 그대로 유지 가능. 필요시 controlled `<select>`로 전환해도 무방. |

수정 후에는 props에서 넘어온 값과 UI가 완전히 일치하고, Playwright/Vitest 테스트도 통과함.

---

### 5. 추가 대비책 제안
1. **리그레션 테스트**
- `dom.ts` 단위 테스트: `<option selected>` 토글 시 `option.selected`가 true/false로 반영되는지 확인.
- 단순 컴포넌트 테스트: 상태 변경 → render → DOM 값 비교.

2. **E2E 케이스 확장**
- Playwright 시나리오에 “URL 파라미터로 접근했을 때 select 값이 동일한지” 검증을 명시적으로 포함.

3. **문서화**
- 본 문서처럼 렌더러 문제를 추적한 사례를 docs에 기록해 팀 공유.

---

### 6. 마무리
이번 문제는 브라우저의 기본 동작(옵션 기본 선택)과 MiniReact의 DOM 적용 방식 간의 미묘한 차이에서 비롯되었다. UI 코드만 수정하기보다 렌더러 레벨에서 근본 원인을 차단해야 함을 확인했고, `selected`를 DOM 프로퍼티로 다루도록 개선해 문제를 해결했다. 향후 유사 이슈에 대비해 DOM 레벨 테스트, Playwright 시나리오, 문서화를 병행하는 것이 좋다.

3 changes: 1 addition & 2 deletions packages/app/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,16 @@ const useForceUpdate = () => {
const [, setTick] = useState(0);
return () => setTick((tick) => tick + 1);
};

export function App() {
const forceUpdate = useForceUpdate();
const PageComponent = router.target;

useEffect(() => {
// 각 Store의 변화를 감지하여 자동 렌더링
productStore.subscribe(forceUpdate);
cartStore.subscribe(forceUpdate);
uiStore.subscribe(forceUpdate);
router.subscribe(forceUpdate);
forceUpdate();
}, []);

return <PageComponent />;
Expand Down
14 changes: 12 additions & 2 deletions packages/app/src/lib/createObserver.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
export const createObserver = () => {
const listeners = new Set();
const subscribe = (fn) => listeners.add(fn);
const notify = () => listeners.forEach((listener) => listener());
const subscribe = (fn) => {
listeners.add(fn);
};
const notify = () => {
listeners.forEach((listener) => {
try {
listener();
} catch (error) {
console.error("[createObserver] 구독자 실행 중 오류:", error);
}
});
};

return { subscribe, notify };
};
5 changes: 5 additions & 0 deletions packages/app/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react",
"jsxFactory": "createElement",
"jsxFragmentFactory": "Fragment"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "build"]
}
1 change: 1 addition & 0 deletions packages/react/src/core/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
//core/constants.ts
import { ValueOf } from "../types";

export const TEXT_ELEMENT = Symbol("mini-react.text");
Expand Down
Loading