diff --git a/eslint.config.js b/eslint.config.js index 09fe501c..ad413039 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,4 +12,14 @@ export default tseslint.config([ ...tseslint.configs.recommended, eslintPluginPrettier, eslintConfigPrettier, + { + rules: { + "prettier/prettier": [ + "error", + { + endOfLine: "auto", + }, + ], + }, + }, ]); diff --git a/packages/react/src/core/constants.ts b/packages/react/src/core/constants.ts index efa85027..a082b738 100644 --- a/packages/react/src/core/constants.ts +++ b/packages/react/src/core/constants.ts @@ -15,6 +15,7 @@ export const NodeTypes = { export type NodeType = ValueOf; export const HookTypes = { + STATE: "state", EFFECT: "effect", } as const; diff --git a/packages/react/src/core/context.ts b/packages/react/src/core/context.ts index bf41d61d..d5c2d1c5 100644 --- a/packages/react/src/core/context.ts +++ b/packages/react/src/core/context.ts @@ -13,8 +13,9 @@ export const context: Context = { node: null, instance: null, reset({ container, node }) { - // 여기를 구현하세요. - // container, node, instance를 전달받은 값으로 초기화합니다. + this.container = container; + this.node = node; + this.instance = null; }, }, @@ -32,36 +33,48 @@ export const context: Context = { * 모든 훅 관련 상태를 초기화합니다. */ clear() { - // 여기를 구현하세요. - // state, cursor, visited, componentStack을 모두 비웁니다. + this.state.clear(); + this.cursor.clear(); + this.visited.clear(); + this.componentStack = []; }, /** * 현재 실행 중인 컴포넌트의 고유 경로를 반환합니다. */ get currentPath() { - // 여기를 구현하세요. - // componentStack의 마지막 요소를 반환해야 합니다. - // 스택이 비어있으면 '훅은 컴포넌트 내부에서만 호출되어야 한다'는 에러를 발생시켜야 합니다. - return ""; + if (this.componentStack.length === 0) { + throw new Error("훅은 컴포넌트 내부에서만 호출되어야 합니다"); + } + return this.componentStack[this.componentStack.length - 1]; }, /** * 현재 컴포넌트에서 다음에 실행될 훅의 인덱스(커서)를 반환합니다. */ get currentCursor() { - // 여기를 구현하세요. - // cursor Map에서 현재 경로의 커서를 가져옵니다. 없으면 0을 반환합니다. - return 0; + const path = this.currentPath; + return this.cursor.get(path) || 0; }, /** * 현재 컴포넌트의 훅 상태 배열을 반환합니다. */ get currentHooks() { - // 여기를 구현하세요. - // state Map에서 현재 경로의 훅 배열을 가져옵니다. 없으면 빈 배열을 반환합니다. - return []; + const path = this.currentPath; + if (!this.state.has(path)) { + this.state.set(path, []); + } + return this.state.get(path)!; + }, + + /** + * 현재 컴포넌트의 훅 커서를 다음 위치로 이동시킵니다. + */ + moveCursor() { + const path = this.currentPath; + const currentCursor = this.cursor.get(path) || 0; + this.cursor.set(path, currentCursor + 1); }, }, @@ -71,4 +84,4 @@ export const context: Context = { effects: { queue: [], }, -}; \ No newline at end of file +}; diff --git a/packages/react/src/core/dom.ts b/packages/react/src/core/dom.ts index f07fc5cb..83cb1b53 100644 --- a/packages/react/src/core/dom.ts +++ b/packages/react/src/core/dom.ts @@ -1,13 +1,71 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { NodeType, NodeTypes } from "./constants"; +import { NodeTypes } from "./constants"; import { Instance } from "./types"; +const isFunctionHandler = ({ key, value }: { key: string; value: any }) => { + return key.startsWith("on") && typeof value === "function"; +}; + +const setSingleProp = (dom: HTMLElement, { key, value }: { key: string; value: any }) => { + if (key === "children") return; + + if (isFunctionHandler({ key, value })) { + const eventType = key.slice(2).toLowerCase(); + dom.addEventListener(eventType, value); + return; + } + + // className을 class로 변환 + if (key === "className") { + dom.setAttribute("class", value); + return; + } + + // htmlFor를 for로 변환 + if (key === "htmlFor") { + dom.setAttribute("for", value); + return; + } + + // style 객체 처리 + if (key === "style" && typeof value === "object") { + const cssText = Object.entries(value) + .map(([styleKey, styleValue]) => { + // camelCase를 kebab-case로 변환 (fontSize -> font-size) + const kebabKey = styleKey.replace(/([A-Z])/g, "-$1").toLowerCase(); + return `${kebabKey}: ${styleValue}`; + }) + .join("; "); + (dom as any).style.cssText = cssText; + return; + } + + // 불린 속성 처리 + if (typeof value === "boolean") { + if (value) { + dom.setAttribute(key, ""); + } + return; + } + + // null이나 undefined는 속성 제거 + if (value == null) { + dom.removeAttribute(key); + return; + } + + // 일반 속성 + dom.setAttribute(key, String(value)); +}; + /** * DOM 요소에 속성(props)을 설정합니다. * 이벤트 핸들러, 스타일, className 등 다양한 속성을 처리해야 합니다. */ export const setDomProps = (dom: HTMLElement, props: Record): void => { - // 여기를 구현하세요. + Object.entries(props).forEach(([key, value]) => { + setSingleProp(dom, { key, value }); + }); }; /** @@ -19,7 +77,22 @@ export const updateDomProps = ( prevProps: Record = {}, nextProps: Record = {}, ): void => { - // 여기를 구현하세요. + for (const [key, value] of Object.entries(nextProps)) { + if (prevProps[key] !== nextProps[key]) { + if (isFunctionHandler({ key, value })) { + const eventType = key.slice(2).toLowerCase(); + dom.removeEventListener(eventType, prevProps[key]); + } + + setSingleProp(dom, { key, value }); + } + } + + for (const key of Object.keys(prevProps)) { + if (nextProps[key] === undefined) { + dom.removeAttribute(key); + } + } }; /** @@ -27,23 +100,50 @@ export const updateDomProps = ( * Fragment나 컴포넌트 인스턴스는 여러 개의 DOM 노드를 가질 수 있습니다. */ export const getDomNodes = (instance: Instance | null): (HTMLElement | Text)[] => { - // 여기를 구현하세요. - return []; + if (!instance) { + return []; + } + + // HOST나 TEXT 타입이면 직접 DOM 노드를 가지고 있음 + if (instance.kind === NodeTypes.HOST || instance.kind === NodeTypes.TEXT) { + return instance.dom ? [instance.dom] : []; + } + + // COMPONENT나 FRAGMENT 타입이면 자식들로부터 DOM 노드 수집 + const nodes: (HTMLElement | Text)[] = []; + for (const child of instance.children) { + nodes.push(...getDomNodes(child)); + } + return nodes; }; /** * 주어진 인스턴스에서 첫 번째 실제 DOM 노드를 찾습니다. */ export const getFirstDom = (instance: Instance | null): HTMLElement | Text | null => { - // 여기를 구현하세요. - return null; + if (!instance) { + return null; + } + + // HOST나 TEXT 타입이면 직접 DOM 노드를 반환 + if (instance.kind === NodeTypes.HOST || instance.kind === NodeTypes.TEXT) { + return instance.dom; + } + + // COMPONENT나 FRAGMENT 타입이면 자식들로부터 첫 번째 DOM 노드 찾기 + return getFirstDomFromChildren(instance.children); }; /** * 자식 인스턴스들로부터 첫 번째 실제 DOM 노드를 찾습니다. */ export const getFirstDomFromChildren = (children: (Instance | null)[]): HTMLElement | Text | null => { - // 여기를 구현하세요. + for (const child of children) { + const dom = getFirstDom(child); + if (dom) { + return dom; + } + } return null; }; @@ -56,12 +156,47 @@ export const insertInstance = ( instance: Instance | null, anchor: HTMLElement | Text | null = null, ): void => { - // 여기를 구현하세요. + if (!instance) { + return; + } + + // 타입별 최적화된 처리 + switch (instance.kind) { + case NodeTypes.HOST: + case NodeTypes.TEXT: + // 직접 DOM이 있는 경우 바로 삽입 + if (instance.dom) { + parentDom.insertBefore(instance.dom, anchor); + } + break; + + case NodeTypes.COMPONENT: + case NodeTypes.FRAGMENT: { + // 자식들을 재귀적으로 삽입 + const domNodes = getDomNodes(instance); + for (const domNode of domNodes) { + if (domNode) { + parentDom.insertBefore(domNode, anchor); + } + } + break; + } + } }; /** * 부모 DOM에서 인스턴스에 해당하는 모든 DOM 노드를 제거합니다. */ export const removeInstance = (parentDom: HTMLElement, instance: Instance | null): void => { - // 여기를 구현하세요. + if (!instance) { + return; + } + + // DOM 노드들 제거 + const domNodes = getDomNodes(instance); + for (const domNode of domNodes) { + if (domNode && domNode.parentNode === parentDom) { + parentDom.removeChild(domNode); + } + } }; diff --git a/packages/react/src/core/elements.ts b/packages/react/src/core/elements.ts index d04bce98..dc213631 100644 --- a/packages/react/src/core/elements.ts +++ b/packages/react/src/core/elements.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { isEmptyValue } from "../utils"; import { VNode } from "./types"; @@ -8,16 +9,45 @@ import { Fragment, TEXT_ELEMENT } from "./constants"; * null, undefined, boolean, 배열, 원시 타입 등을 처리하여 일관된 VNode 구조를 보장합니다. */ export const normalizeNode = (node: VNode): VNode | null => { - // 여기를 구현하세요. + // null, undefined, boolean 등 빈 값들은 렌더링하지 않음 + if (isEmptyValue(node)) { + return null; + } + + // 문자열이나 숫자는 텍스트 노드로 변환 + if (typeof node === "string" || typeof node === "number") { + return createTextElement(String(node)); + } + + // 배열인 경우 평탄화하여 처리 + if (Array.isArray(node)) { + // 배열을 평탄화하고 각 요소를 정규화 + return node + .flat(Infinity) + .map(normalizeNode) + .filter((child) => child !== null); + } + + // 이미 VNode 객체인 경우 그대로 반환 + if (node && typeof node === "object" && "type" in node) { + return node; + } + return null; }; /** * 텍스트 노드를 위한 VNode를 생성합니다. */ -const createTextElement = (node: VNode): VNode => { - // 여기를 구현하세요. - return {} as VNode; +const createTextElement = (text: string): VNode => { + return { + type: TEXT_ELEMENT, + key: null, + props: { + children: [], + nodeValue: text, + }, + }; }; /** @@ -29,7 +59,28 @@ export const createElement = ( originProps?: Record | null, ...rawChildren: any[] ) => { - // 여기를 구현하세요. + const props = originProps || {}; + + // key를 props에서 추출 + const key = props.key || null; + + // children 정규화: 평탄화하고 null 제거 + const children = rawChildren + .flat(Infinity) + .map(normalizeNode) + .filter((child) => child !== null); + + // key를 제외한 나머지 props + const { key: _, ...restProps } = props; + + // 자식이 있는 경우만 children 속성 추가 + const nodeProps = children.length > 0 ? { ...restProps, children } : restProps; + + return { + type, + key, + props: nodeProps, + }; }; /** @@ -43,6 +94,23 @@ export const createChildPath = ( nodeType?: string | symbol | React.ComponentType, siblings?: VNode[], ): string => { - // 여기를 구현하세요. - return ""; + let pathSegment: string; + + if (key !== null) { + // key가 있으면 key 기반 경로 + pathSegment = `k${key}`; + } else { + // key가 없으면 타입별 인덱스 사용 + if (typeof nodeType === "function") { + // 컴포넌트인 경우: 컴포넌트 이름 + 타입별 인덱스 + const componentName = nodeType.name || "Component"; + pathSegment = `c${componentName}${index}`; + } else { + // HTML 요소나 기타인 경우: 일반 인덱스 + pathSegment = `i${index}`; + } + } + + // 부모 경로가 비어있으면 현재 세그먼트만 반환, 아니면 점으로 연결 + return parentPath ? `${parentPath}.${pathSegment}` : pathSegment; }; diff --git a/packages/react/src/core/hooks.ts b/packages/react/src/core/hooks.ts index ef35d0f6..f780f77b 100644 --- a/packages/react/src/core/hooks.ts +++ b/packages/react/src/core/hooks.ts @@ -1,14 +1,39 @@ import { shallowEquals, withEnqueue } from "../utils"; import { context } from "./context"; -import { EffectHook } from "./types"; +import { EffectHook, StateHook } from "./types"; import { enqueueRender } from "./render"; import { HookTypes } from "./constants"; /** - * 사용되지 않는 컴포넌트의 훅 상태와 이펙트 클린업 함수를 정리합니다. + * 사용되지 않는 컴포넌트의 훅 상태를 정리합니다. + * (Effect cleanup은 removeInstance에서 실행되므로 여기서는 상태만 정리) */ export const cleanupUnusedHooks = () => { - // 여기를 구현하세요. + // state에는 있지만 visited에는 없는 컴포넌트들 = 제거된 컴포넌트들 + for (const [path] of context.hooks.state) { + if (!context.hooks.visited.has(path)) { + // 이 컴포넌트는 더 이상 존재하지 않으므로 훅 상태 정리 + context.hooks.state.delete(path); + context.hooks.cursor.delete(path); + } + } +}; + +/** + * 특정 경로의 컴포넌트에 대한 Effect cleanup을 실행합니다. + */ +export const cleanupComponentEffects = (path: string) => { + const hooks = context.hooks.state.get(path); + if (hooks) { + hooks.forEach((hook) => { + if (hook.kind === HookTypes.EFFECT) { + const effectHook = hook as EffectHook; + if (effectHook.cleanup) { + effectHook.cleanup(); + } + } + }); + } }; /** @@ -17,15 +42,42 @@ export const cleanupUnusedHooks = () => { * @returns [현재 상태, 상태를 업데이트하는 함수] */ export const useState = (initialValue: T | (() => T)): [T, (nextValue: T | ((prev: T) => T)) => void] => { - // 여기를 구현하세요. - // 1. 현재 컴포넌트의 훅 커서와 상태 배열을 가져옵니다. + // 1. 현재 컴포넌트의 경로와 훅 커서를 가져옵니다. + const currentCursor = context.hooks.currentCursor; + const currentHooks = context.hooks.currentHooks; + // 2. 첫 렌더링이라면 초기값으로 상태를 설정합니다. + if (!currentHooks[currentCursor]) { + const value = typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + + currentHooks[currentCursor] = { + kind: HookTypes.STATE, + value, + }; + } + + const hook = currentHooks[currentCursor] as StateHook; + const currentValue = hook.value as T; + // 3. 상태 변경 함수(setter)를 생성합니다. - // - 새 값이 이전 값과 같으면(Object.is) 재렌더링을 건너뜁니다. - // - 값이 다르면 상태를 업데이트하고 재렌더링을 예약(enqueueRender)합니다. + const setState = (nextValue: T | ((prev: T) => T)) => { + // 최신 상태 값을 가져옵니다 (동일한 이벤트 루프에서 여러 번 호출될 수 있음) + const latestValue = hook.value as T; + const newValue = typeof nextValue === "function" ? (nextValue as (prev: T) => T)(latestValue) : nextValue; + + // 새 값이 이전 값과 같으면(Object.is) 재렌더링을 건너뜁니다. + if (Object.is(newValue, latestValue)) { + return; + } + + // 값이 다르면 상태를 업데이트하고 재렌더링을 예약(enqueueRender)합니다. + hook.value = newValue; + enqueueRender(); + }; + // 4. 훅 커서를 증가시키고 [상태, setter]를 반환합니다. - const setState = (nextValue: T | ((prev: T) => T)) => {}; - return [initialValue as T, setState]; + context.hooks.moveCursor(); + return [currentValue, setState]; }; /** @@ -34,9 +86,54 @@ export const useState = (initialValue: T | (() => T)): [T, (nextValue: T | (( * @param deps - 의존성 배열. 이 값들이 변경될 때만 이펙트가 다시 실행됩니다. */ export const useEffect = (effect: () => (() => void) | void, deps?: unknown[]): void => { - // 여기를 구현하세요. - // 1. 이전 훅의 의존성 배열과 현재 의존성 배열을 비교(shallowEquals)합니다. - // 2. 의존성이 변경되었거나 첫 렌더링일 경우, 이펙트 실행을 예약합니다. - // 3. 이펙트 실행 전, 이전 클린업 함수가 있다면 먼저 실행합니다. - // 4. 예약된 이펙트는 렌더링이 끝난 후 비동기로 실행됩니다. + const currentCursor = context.hooks.currentCursor; + const currentHooks = context.hooks.currentHooks; + + // 첫 렌더링이면 훅 데이터 초기화 (effect는 실행하지 않고 저장만) + if (!currentHooks[currentCursor]) { + currentHooks[currentCursor] = { + kind: HookTypes.EFFECT, + deps: deps || null, + cleanup: null, + effect: effect, + }; + + // 첫 렌더링이므로 effect 실행 예약 + const executeEffect = withEnqueue(() => { + const cleanup = effect(); + if (cleanup && typeof cleanup === "function") { + (currentHooks[currentCursor] as EffectHook).cleanup = cleanup; + } + }); + executeEffect(); + } else { + // 기존 훅이 있으면 의존성 비교 + const hook = currentHooks[currentCursor] as EffectHook; + + // 의존성이 변경되었는지 확인 + const depsChanged = !shallowEquals(hook.deps, deps); + + if (depsChanged) { + // effect와 deps 업데이트 + hook.effect = effect; + hook.deps = deps || null; + + // 이전 cleanup 실행 후 새 effect 예약 + const executeEffect = withEnqueue(() => { + if (hook.cleanup) { + hook.cleanup(); + hook.cleanup = null; + } + + const cleanup = effect(); + if (cleanup && typeof cleanup === "function") { + hook.cleanup = cleanup; + } + }); + executeEffect(); + } + } + + // 훅 커서 이동 + context.hooks.moveCursor(); }; diff --git a/packages/react/src/core/reconciler.ts b/packages/react/src/core/reconciler.ts index 12cbdd39..748ce0a0 100644 --- a/packages/react/src/core/reconciler.ts +++ b/packages/react/src/core/reconciler.ts @@ -1,16 +1,39 @@ import { context } from "./context"; -import { Fragment, NodeTypes, TEXT_ELEMENT } from "./constants"; -import { Instance, VNode } from "./types"; -import { - getFirstDom, - getFirstDomFromChildren, - insertInstance, - removeInstance, - setDomProps, - updateDomProps, -} from "./dom"; +import { Fragment, NodeTypes, TEXT_ELEMENT, HookTypes } from "./constants"; +import { Instance, VNode, EffectHook } from "./types"; +import { insertInstance, removeInstance, setDomProps, updateDomProps } from "./dom"; import { createChildPath } from "./elements"; -import { isEmptyValue } from "../utils"; + +/** + * 인스턴스 트리를 순회하여 모든 컴포넌트의 Effect cleanup을 실행합니다. + */ +const cleanupInstanceEffects = (instance: Instance | null): void => { + if (!instance) { + return; + } + + // 컴포넌트 인스턴스인 경우 Effect cleanup 실행 + if (instance.kind === NodeTypes.COMPONENT) { + const hooks = context.hooks.state.get(instance.path); + if (hooks) { + hooks.forEach((hook) => { + if (hook.kind === HookTypes.EFFECT) { + const effectHook = hook as EffectHook; + if (effectHook.cleanup) { + effectHook.cleanup(); + } + } + }); + } + } + + // 자식들도 재귀적으로 cleanup + if (instance.children) { + for (const child of instance.children) { + cleanupInstanceEffects(child); + } + } +}; /** * 이전 인스턴스와 새로운 VNode를 비교하여 DOM을 업데이트하는 재조정 과정을 수행합니다. @@ -27,12 +50,294 @@ export const reconcile = ( node: VNode | null, path: string, ): Instance | null => { - // 여기를 구현하세요. // 1. 새 노드가 null이면 기존 인스턴스를 제거합니다. (unmount) + if (node === null) { + if (instance) { + removeInstance(parentDom, instance); + } + return null; + } // 2. 기존 인스턴스가 없으면 새 노드를 마운트합니다. (mount) + if (!instance) { + // 간단한 인스턴스 생성 (텍스트 노드만 처리) + if (node.type === TEXT_ELEMENT) { + const newTextInstance: Instance = createTextInstance({ node, path }); + insertInstance(parentDom, newTextInstance); + return newTextInstance; + } + + // Fragment 처리 + if (node.type === Fragment) { + const newFragmentInstance: Instance = createFragmentInstance({ node, path, parentDom }); + // Fragment는 DOM이 없으므로 insertInstance 호출하지 않음 + return newFragmentInstance; + } + + // HTML 요소 처리 + if (typeof node.type === "string") { + const newHTMLInstance: Instance = createHTMLInstance({ node, path }); + insertInstance(parentDom, newHTMLInstance); + return newHTMLInstance; + } + + // React 컴포넌트 처리 + if (typeof node.type === "function") { + const newComponentInstance: Instance = createComponentInstance({ node, path, parentDom }); + // 컴포넌트는 DOM이 없으므로 insertInstance 호출하지 않음 + return newComponentInstance; + } + + return null; + } // 3. 타입이나 키가 다르면 기존 인스턴스를 제거하고 새로 마운트합니다. + if (instance.node.type !== node.type || instance.node.key !== node.key) { + // 컴포넌트 인스턴스인 경우 Effect cleanup 실행 + cleanupInstanceEffects(instance); + // 기존 인스턴스 제거 + removeInstance(parentDom, instance); + // 새 노드 마운트 (재귀 호출) + return reconcile(parentDom, null, node, path); + } + // 4. 타입과 키가 같으면 인스턴스를 업데이트합니다. (update) - // - DOM 요소: updateDomProps로 속성 업데이트 후 자식 재조정 - // - 컴포넌트: 컴포넌트 함수 재실행 후 자식 재조정 - return null; + // 이전 props 저장 (DOM 업데이트 비교용) + const oldProps = instance.node.props; + + // 인스턴스의 기본 정보 업데이트 + instance.node = node; + instance.path = path; + + // 텍스트 노드 업데이트 + if (node.type === TEXT_ELEMENT) { + const textContent = node.props.nodeValue || ""; + if (instance.dom && instance.dom.textContent !== textContent) { + instance.dom.textContent = textContent; + } + return instance; + } + + // HTML 요소 업데이트 + if (typeof node.type === "string") { + const { children, ...props } = node.props; + if (instance.dom) { + // DOM 속성 업데이트 (이전 props와 새 props 비교) + updateDomProps(instance.dom as HTMLElement, oldProps, props); + // 자식들 재조정 + instance.children = reconcileChildren(instance.dom as HTMLElement, instance.children, children || [], path); + } + return instance; + } + + // Fragment 업데이트 + if (node.type === Fragment) { + const { children } = node.props; + // Fragment의 자식들을 부모 DOM에 재조정 + instance.children = reconcileChildren(parentDom, instance.children, children || [], path); + return instance; + } + + // React 컴포넌트 업데이트 + if (typeof node.type === "function") { + context.hooks.componentStack.push(path); + context.hooks.visited.add(path); // 방문된 컴포넌트 추가 + try { + // 컴포넌트 함수 재실행 + const Component = node.type as React.ComponentType>; + const childVNode = Component(node.props); + + // 기존 자식과 새 자식 재조정 + if (childVNode) { + const childPath = createChildPath(path, null, 0, childVNode.type); + const childInstance = reconcile(parentDom, instance.children[0] || null, childVNode, childPath); + instance.children = childInstance ? [childInstance] : []; + } else { + // 자식이 없으면 기존 자식들 제거 + instance.children.forEach((child) => { + if (child) removeInstance(parentDom, child); + }); + instance.children = []; + } + return instance; + } finally { + context.hooks.componentStack.pop(); + } + } + + return instance; +}; + +const createTextInstance = ({ node, path }: { node: VNode; path: string }) => { + const textNode = document.createTextNode(node.props.nodeValue || ""); + const newInstance: Instance = { + kind: NodeTypes.TEXT, + dom: textNode, + node, + children: [], + key: node.key, + path, + }; + return newInstance; +}; + +const createHTMLInstance = ({ node, path }: { node: VNode; path: string }) => { + const element = document.createElement(node.type as string); + const { children, ...props } = node.props; + setDomProps(element, props); + + const newInstance: Instance = { + kind: NodeTypes.HOST, + dom: element, + node, + children: [], + key: node.key, + path, + }; + + // children이 있으면 재귀적으로 마운트 + if (children && Array.isArray(children)) { + const childInstances: Instance[] = []; + // 컴포넌트 타입별로 0부터 시작하는 카운터 + const typeCounters = new Map(); + + children.forEach((child, index) => { + if (child) { + const effectiveKey = child.key; + let pathIndex = index; + + // 컴포넌트인 경우 타입별 카운터 사용 (key가 없을 때만) + if (effectiveKey === null && typeof child.type === "function") { + const currentCount = typeCounters.get(child.type) || 0; + pathIndex = currentCount; + typeCounters.set(child.type, currentCount + 1); + } + + const childPath = createChildPath(path, effectiveKey, pathIndex, child.type); + const childInstance = reconcile(element, null, child, childPath); + if (childInstance) { + childInstances.push(childInstance); + } + } + }); + newInstance.children = childInstances; + } + + return newInstance; +}; + +const createFragmentInstance = ({ node, path, parentDom }: { node: VNode; path: string; parentDom: HTMLElement }) => { + const newInstance: Instance = { + kind: NodeTypes.FRAGMENT, + dom: null, // Fragment는 DOM이 없음 + node, + children: [], + key: node.key, + path, + }; + + // children이 있으면 재귀적으로 마운트 (Fragment 자식들을 parentDom에 직접 삽입) + const { children } = node.props; + if (children && Array.isArray(children)) { + const childInstances: Instance[] = []; + // 컴포넌트 타입별로 0부터 시작하는 카운터 + const typeCounters = new Map(); + + children.forEach((child, index) => { + if (child) { + const effectiveKey = child.key; + let pathIndex = index; + + // 컴포넌트인 경우 타입별 카운터 사용 (key가 없을 때만) + if (effectiveKey === null && typeof child.type === "function") { + const currentCount = typeCounters.get(child.type) || 0; + pathIndex = currentCount; + typeCounters.set(child.type, currentCount + 1); + } + + const childPath = createChildPath(path, effectiveKey, pathIndex, child.type); + const childInstance = reconcile(parentDom, null, child, childPath); // parentDom에 직접 삽입 + if (childInstance) { + childInstances.push(childInstance); + } + } + }); + newInstance.children = childInstances; + } + + return newInstance; +}; + +const reconcileChildren = ( + parentDom: HTMLElement, + oldChildren: (Instance | null)[], + newChildren: VNode[], + parentPath: string, +): (Instance | null)[] => { + const childInstances: (Instance | null)[] = []; + const maxLength = Math.max(oldChildren.length, newChildren.length); + + // 컴포넌트 타입별로 0부터 시작하는 카운터 + const typeCounters = new Map(); + + for (let i = 0; i < maxLength; i++) { + const oldChild = oldChildren[i]; + const newChild = newChildren[i]; + + if (newChild) { + const effectiveKey = newChild.key; + let pathIndex = i; + + // 컴포넌트인 경우 타입별 카운터 사용 (key가 없을 때만) + if (effectiveKey === null && typeof newChild.type === "function") { + const currentCount = typeCounters.get(newChild.type) || 0; + pathIndex = currentCount; + typeCounters.set(newChild.type, currentCount + 1); + } + + const childPath = createChildPath(parentPath, effectiveKey, pathIndex, newChild.type); + const childInstance = reconcile(parentDom, oldChild, newChild, childPath); + childInstances.push(childInstance); + } else if (oldChild) { + // 새 자식이 없으면 기존 자식 제거 + cleanupInstanceEffects(oldChild); + removeInstance(parentDom, oldChild); + childInstances.push(null); + } + } + + return childInstances.filter((child) => child !== null); +}; + +const createComponentInstance = ({ node, path, parentDom }: { node: VNode; path: string; parentDom: HTMLElement }) => { + // 컴포넌트 스택에 현재 경로 추가 (훅 실행을 위해) + context.hooks.componentStack.push(path); + context.hooks.visited.add(path); // 방문된 컴포넌트 추가 + + try { + // 컴포넌트 함수 실행 + const Component = node.type as React.ComponentType>; + const childVNode = Component(node.props); + + const newInstance: Instance = { + kind: NodeTypes.COMPONENT, + dom: null, // 컴포넌트 자체는 DOM이 없음 + node, + children: [], + key: node.key, + path, + }; + + // 컴포넌트 실행 결과를 자식으로 마운트 + if (childVNode) { + const childPath = createChildPath(path, null, 0, childVNode.type); + const childInstance = reconcile(parentDom, null, childVNode, childPath); + if (childInstance) { + newInstance.children = [childInstance]; + } + } + + return newInstance; + } finally { + // 컴포넌트 스택에서 제거 (훅 컨텍스트 정리) + context.hooks.componentStack.pop(); + } }; diff --git a/packages/react/src/core/render.ts b/packages/react/src/core/render.ts index 79c4bbb8..fdae6679 100644 --- a/packages/react/src/core/render.ts +++ b/packages/react/src/core/render.ts @@ -1,7 +1,6 @@ import { context } from "./context"; -import { getDomNodes, insertInstance } from "./dom"; +// import { getDomNodes, insertInstance } from "./dom"; import { reconcile } from "./reconciler"; -import { cleanupUnusedHooks } from "./hooks"; import { withEnqueue } from "../utils"; /** @@ -9,10 +8,36 @@ import { withEnqueue } from "../utils"; * `enqueueRender`에 의해 스케줄링되어 호출됩니다. */ export const render = (): void => { - // 여기를 구현하세요. - // 1. 훅 컨텍스트를 초기화합니다. - // 2. reconcile 함수를 호출하여 루트 노드를 재조정합니다. - // 3. 사용되지 않은 훅들을 정리(cleanupUnusedHooks)합니다. + // 1. 컨텍스트에서 필요한 정보 가져오기 + const { container, node } = context.root; + + if (!container || !node) { + return; + } + + // 2. 방문된 컴포넌트들의 훅 커서만 리셋 + for (const path of context.hooks.visited) { + context.hooks.cursor.set(path, 0); + } + + // 3. 새로운 렌더링 사이클을 위한 visited 준비 + const previousVisited = new Set(context.hooks.visited); + context.hooks.visited.clear(); + + // 4. reconcile 함수를 호출하여 루트 노드를 재조정 + const newInstance = reconcile(container, context.root.instance, node, "i0"); + + // 5. 루트 인스턴스 업데이트 + context.root.instance = newInstance; + + // 6. 사용되지 않은 훅들을 정리 (visited 클리어 전의 상태로 확인) + for (const [path] of context.hooks.state) { + if (!context.hooks.visited.has(path) && previousVisited.has(path)) { + // 이전에 존재했지만 현재 렌더링에서 방문되지 않은 컴포넌트 정리 + context.hooks.state.delete(path); + context.hooks.cursor.delete(path); + } + } }; /** diff --git a/packages/react/src/core/setup.ts b/packages/react/src/core/setup.ts index 03813995..651104b0 100644 --- a/packages/react/src/core/setup.ts +++ b/packages/react/src/core/setup.ts @@ -11,9 +11,31 @@ import { render } from "./render"; * @param container - VNode가 렌더링될 DOM 컨테이너 */ export const setup = (rootNode: VNode | null, container: HTMLElement): void => { - // 여기를 구현하세요. // 1. 컨테이너 유효성을 검사합니다. - // 2. 이전 렌더링 내용을 정리하고 컨테이너를 비웁니다. + if (!rootNode || !container) { + throw new Error("rootNode와 container는 필수입니다"); + } + + // 2. 이전 렌더링 내용을 정리합니다. + if (context.root.instance) { + removeInstance(container, context.root.instance); + } + + // 미사용 훅 정리 + cleanupUnusedHooks(); + + // 컨테이너를 비웁니다. + container.innerHTML = ""; + // 3. 루트 컨텍스트와 훅 컨텍스트를 리셋합니다. + context.root.reset({ + container, + node: rootNode, + }); + + // 훅 컨텍스트도 초기화 + context.hooks.clear(); + // 4. 첫 렌더링을 실행합니다. + render(); }; diff --git a/packages/react/src/core/types.ts b/packages/react/src/core/types.ts index d88c5714..c1f37913 100644 --- a/packages/react/src/core/types.ts +++ b/packages/react/src/core/types.ts @@ -30,6 +30,12 @@ export interface EffectHook { effect: () => (() => void) | void; } +export interface StateHook { + kind: HookType["STATE"]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; +} + export interface RootContext { container: HTMLElement | null; node: VNode | null; @@ -38,20 +44,20 @@ export interface RootContext { reset(options: { container: HTMLElement; node: VNode }): void; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type State = any; +export type HookData = StateHook | EffectHook; export interface HooksContext { - state: Map; + state: Map; cursor: Map; visited: Set; componentStack: string[]; clear(): void; + moveCursor(): void; readonly currentPath: string; readonly currentCursor: number; - readonly currentHooks: State[]; + readonly currentHooks: HookData[]; } export interface EffectsContext { diff --git a/packages/react/src/hocs/memo.ts b/packages/react/src/hocs/memo.ts index 24569ce4..301adb92 100644 --- a/packages/react/src/hocs/memo.ts +++ b/packages/react/src/hocs/memo.ts @@ -12,10 +12,15 @@ import { shallowEquals } from "../utils"; */ export function memo

(Component: FunctionComponent

, equals = shallowEquals) { const MemoizedComponent: FunctionComponent

= (props) => { - // 여기를 구현하세요. - // useRef를 사용하여 이전 props와 렌더링 결과를 저장해야 합니다. - // equals 함수로 이전 props와 현재 props를 비교하여 렌더링 여부를 결정합니다. - return Component(props); + const _props = useRef

(undefined); + const _prevRender = useRef(undefined); + + if (_props.current === undefined || !equals(_props.current, props)) { + _prevRender.current = Component(props); + _props.current = props; // 🔥 이 부분이 빠져있었습니다! + } + + return _prevRender.current as VNode | null; }; MemoizedComponent.displayName = `Memo(${Component.displayName || Component.name})`; diff --git a/packages/react/src/hooks/useAutoCallback.ts b/packages/react/src/hooks/useAutoCallback.ts index 19d48f72..c30be534 100644 --- a/packages/react/src/hooks/useAutoCallback.ts +++ b/packages/react/src/hooks/useAutoCallback.ts @@ -9,7 +9,15 @@ import { useRef } from "./useRef"; * @returns 참조가 안정적인 콜백 함수 */ export const useAutoCallback = (fn: T): T => { - // 여기를 구현하세요. - // useRef와 useCallback을 조합하여 구현해야 합니다. - return fn; + // 최신 함수를 ref에 저장 (렌더링마다 업데이트) + const fnRef = useRef(fn); + fnRef.current = fn; + + // 안정적인 래퍼 함수 생성 (참조는 변경되지 않음) + const stableWrapper = useCallback((...args: unknown[]) => { + // 실행할 때마다 최신 함수 호출 + return fnRef.current(...args); + }, []); + + return stableWrapper as T; }; diff --git a/packages/react/src/hooks/useCallback.ts b/packages/react/src/hooks/useCallback.ts index c0043993..52bcb532 100644 --- a/packages/react/src/hooks/useCallback.ts +++ b/packages/react/src/hooks/useCallback.ts @@ -9,8 +9,6 @@ import { useMemo } from "./useMemo"; * @param deps - 의존성 배열 * @returns 메모이제이션된 콜백 함수 */ -export const useCallback = any>(callback: T, deps: DependencyList): T => { - // 여기를 구현하세요. - // useMemo를 사용하여 구현할 수 있습니다. - return callback; +export const useCallback = unknown>(callback: T, deps: DependencyList): T => { + return useMemo(() => callback, deps); }; diff --git a/packages/react/src/hooks/useDeepMemo.ts b/packages/react/src/hooks/useDeepMemo.ts index f968d05a..d7ca8735 100644 --- a/packages/react/src/hooks/useDeepMemo.ts +++ b/packages/react/src/hooks/useDeepMemo.ts @@ -8,5 +8,5 @@ import { useMemo } from "./useMemo"; export const useDeepMemo = (factory: () => T, deps: DependencyList): T => { // 여기를 구현하세요. // useMemo와 deepEquals 함수를 사용해야 합니다. - return factory(); + return useMemo(factory, deps, deepEquals); }; diff --git a/packages/react/src/hooks/useMemo.ts b/packages/react/src/hooks/useMemo.ts index c275d0e1..3908f6f3 100644 --- a/packages/react/src/hooks/useMemo.ts +++ b/packages/react/src/hooks/useMemo.ts @@ -12,8 +12,13 @@ import { shallowEquals } from "../utils"; * @returns 메모이제이션된 값 */ export const useMemo = (factory: () => T, deps: DependencyList, equals = shallowEquals): T => { - // 여기를 구현하세요. - // useRef를 사용하여 이전 의존성 배열과 계산된 값을 저장해야 합니다. - // equals 함수로 의존성을 비교하여 factory 함수를 재실행할지 결정합니다. - return factory(); + const prevDeps = useRef(undefined); + const memoized = useRef(undefined); + + if (prevDeps.current === undefined || !equals(prevDeps.current, deps)) { + memoized.current = factory(); + prevDeps.current = deps; + } + + return memoized.current!; }; diff --git a/packages/react/src/hooks/useRef.ts b/packages/react/src/hooks/useRef.ts index d5521ca1..f4610d3d 100644 --- a/packages/react/src/hooks/useRef.ts +++ b/packages/react/src/hooks/useRef.ts @@ -8,7 +8,6 @@ import { useState } from "../core"; * @returns `{ current: T }` 형태의 ref 객체 */ export const useRef = (initialValue: T): { current: T } => { - // 여기를 구현하세요. - // useState를 사용하여 ref 객체를 한 번만 생성하도록 해야 합니다. - return { current: initialValue }; + const [state] = useState({ current: initialValue }); + return state; }; diff --git a/packages/react/src/utils/enqueue.ts b/packages/react/src/utils/enqueue.ts index a4957d53..3bd40d64 100644 --- a/packages/react/src/utils/enqueue.ts +++ b/packages/react/src/utils/enqueue.ts @@ -5,7 +5,11 @@ import type { AnyFunction } from "../types"; * 브라우저의 `queueMicrotask` 또는 `Promise.resolve().then()`을 사용합니다. */ export const enqueue = (callback: () => void) => { - // 여기를 구현하세요. + if (typeof queueMicrotask === "function") { + queueMicrotask(callback); + } else { + Promise.resolve().then(callback); + } }; /** @@ -13,7 +17,17 @@ export const enqueue = (callback: () => void) => { * 렌더링이나 이펙트 실행과 같은 작업의 중복을 방지하는 데 사용됩니다. */ export const withEnqueue = (fn: AnyFunction) => { - // 여기를 구현하세요. - // scheduled 플래그를 사용하여 fn이 한 번만 예약되도록 구현합니다. - return () => {}; + let scheduled = false; + + return (...args: Parameters) => { + if (scheduled) { + return; + } + + scheduled = true; + enqueue(() => { + scheduled = false; + fn(...args); + }); + }; }; diff --git a/packages/react/src/utils/equals.ts b/packages/react/src/utils/equals.ts index 31ec4ba5..c0134798 100644 --- a/packages/react/src/utils/equals.ts +++ b/packages/react/src/utils/equals.ts @@ -3,9 +3,13 @@ * 객체와 배열은 1단계 깊이까지만 비교합니다. */ export const shallowEquals = (a: unknown, b: unknown): boolean => { - // 여기를 구현하세요. - // Object.is(), Array.isArray(), Object.keys() 등을 활용하여 1단계 깊이의 비교를 구현합니다. - return a === b; + if (Object.is(a, b)) return true; // typeof null 이 object이기 때문에. + if (typeof a === "object" && typeof b === "object") { + const targetA = Object.entries(a); + const targetB = Object.entries(b); + return targetA.length === targetB.length && targetA.every(([key, value]) => Object.is(value, b[key])); + } + return false; }; /** @@ -13,7 +17,11 @@ export const shallowEquals = (a: unknown, b: unknown): boolean => { * 객체와 배열의 모든 중첩된 속성을 재귀적으로 비교합니다. */ export const deepEquals = (a: unknown, b: unknown): boolean => { - // 여기를 구현하세요. - // 재귀적으로 deepEquals를 호출하여 중첩된 구조를 비교해야 합니다. - return a === b; + if (Object.is(a, b)) return true; // typeof null 이 object이기 때문에. + if (typeof a === "object" && typeof b === "object") { + const targetA = Object.entries(a); + const targetB = Object.entries(b); + return targetA.length === targetB.length && targetA.every(([key, value]) => deepEquals(value, b[key])); + } + return false; }; diff --git a/packages/react/src/utils/validators.ts b/packages/react/src/utils/validators.ts index da81b3dd..af09136d 100644 --- a/packages/react/src/utils/validators.ts +++ b/packages/react/src/utils/validators.ts @@ -6,6 +6,5 @@ * @returns 렌더링되지 않아야 하면 true, 그렇지 않으면 false */ export const isEmptyValue = (value: unknown): boolean => { - // 여기를 구현하세요. - return false; + return value === null || value === undefined || typeof value === "boolean"; };