diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..86631701 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,57 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + - feature-* + workflow_dispatch: + inputs: + branch: + description: "Branch to deploy" + required: true + default: "main" + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "packages/app/dist" + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/packages/react/src/core/commit.ts b/packages/react/src/core/commit.ts new file mode 100644 index 00000000..82dd5600 --- /dev/null +++ b/packages/react/src/core/commit.ts @@ -0,0 +1,21 @@ +import { DomEffect } from "./types"; +import { updateDomProps, insertInstance, removeInstance } from "./dom"; + +export const commitMutations = (mutations: DomEffect[]) => { + for (const mutation of mutations) { + switch (mutation.type) { + case "INSERT": + insertInstance(mutation.parentDOM, mutation.instance, mutation.anchor); + break; + case "REMOVE": + removeInstance(mutation.parentDOM, mutation.instance); + break; + case "UPDATE_PROPS": + updateDomProps(mutation.dom, mutation.prevProps, mutation.nextProps); + break; + case "UPDATE_TEXT": + mutation.dom.nodeValue = mutation.nextText; + break; + } + } +}; diff --git a/packages/react/src/core/constants.ts b/packages/react/src/core/constants.ts index efa85027..bc797583 100644 --- a/packages/react/src/core/constants.ts +++ b/packages/react/src/core/constants.ts @@ -19,3 +19,13 @@ export const HookTypes = { } as const; export type HookType = typeof HookTypes; + +export const BOOLEAN_ATTRIBUTES = [ + "disabled", + "checked", + "readOnly", + "required", + "selected", + "autoFocus", + "multiple", +] as const; diff --git a/packages/react/src/core/context.ts b/packages/react/src/core/context.ts index bf41d61d..ac96c072 100644 --- a/packages/react/src/core/context.ts +++ b/packages/react/src/core/context.ts @@ -1,74 +1,36 @@ -import { Context } from "./types"; +import { StoreContext, RuntimeContext } from "./types"; -/** - * Mini-React의 전역 컨텍스트입니다. - * 렌더링 루트, 훅 상태, 이펙트 큐 등 모든 런타임 데이터를 관리합니다. - */ -export const context: Context = { - /** - * 렌더링 루트와 관련된 정보를 관리합니다. - */ +// Persistent context +export const storeContext: StoreContext = { root: { container: null, node: null, instance: null, - reset({ container, node }) { - // 여기를 구현하세요. - // container, node, instance를 전달받은 값으로 초기화합니다. - }, }, + hooks: new Map(), + cleanupEffects: new Map(), +}; - /** - * 훅의 상태를 관리합니다. - * 컴포넌트 경로(path)를 키로 사용하여 각 컴포넌트의 훅 상태를 격리합니다. - */ - hooks: { - state: new Map(), - cursor: new Map(), - visited: new Set(), - componentStack: [], - - /** - * 모든 훅 관련 상태를 초기화합니다. - */ - clear() { - // 여기를 구현하세요. - // state, cursor, visited, componentStack을 모두 비웁니다. - }, - - /** - * 현재 실행 중인 컴포넌트의 고유 경로를 반환합니다. - */ - get currentPath() { - // 여기를 구현하세요. - // componentStack의 마지막 요소를 반환해야 합니다. - // 스택이 비어있으면 '훅은 컴포넌트 내부에서만 호출되어야 한다'는 에러를 발생시켜야 합니다. - return ""; - }, - - /** - * 현재 컴포넌트에서 다음에 실행될 훅의 인덱스(커서)를 반환합니다. - */ - get currentCursor() { - // 여기를 구현하세요. - // cursor Map에서 현재 경로의 커서를 가져옵니다. 없으면 0을 반환합니다. - return 0; - }, - - /** - * 현재 컴포넌트의 훅 상태 배열을 반환합니다. - */ - get currentHooks() { - // 여기를 구현하세요. - // state Map에서 현재 경로의 훅 배열을 가져옵니다. 없으면 빈 배열을 반환합니다. - return []; - }, +// Temporary context for each render +export const runtimeContext: RuntimeContext = { + cursor: { + path: null, + index: 0, }, - - /** - * useEffect 훅의 실행을 관리하는 큐입니다. - */ - effects: { - queue: [], + workQueue: { + domMutations: [], + passiveEffects: [], + cleanups: [], }, -}; \ No newline at end of file + componentStack: [], + visited: new Set(), +}; + +export const resetRuntime = () => { + runtimeContext.cursor.path = null; + runtimeContext.cursor.index = 0; + runtimeContext.workQueue.domMutations = []; + runtimeContext.workQueue.passiveEffects = []; + runtimeContext.workQueue.cleanups = []; + runtimeContext.visited.clear(); +}; diff --git a/packages/react/src/core/dom.ts b/packages/react/src/core/dom.ts index f07fc5cb..0ae69f23 100644 --- a/packages/react/src/core/dom.ts +++ b/packages/react/src/core/dom.ts @@ -1,13 +1,77 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { NodeType, NodeTypes } from "./constants"; -import { Instance } from "./types"; +import { BOOLEAN_ATTRIBUTES, NodeTypes, TEXT_ELEMENT, Fragment } from "./constants"; +import { Instance, VNode } from "./types"; +import { createChildPath } from "./elements"; +import { hookManager } from "./hookManager"; + +const normalizeClassName = (className: any): string => { + if (!className) return ""; + + if (typeof className === "string") { + return className.trim(); + } + + if (Array.isArray(className)) { + return className + .map((item) => normalizeClassName(item)) + .filter(Boolean) + .join(" ") + .trim(); + } + + if (typeof className === "object") { + return Object.keys(className) + .filter((key) => className[key]) + .join(" ") + .trim(); + } + + return ""; +}; /** * DOM 요소에 속성(props)을 설정합니다. * 이벤트 핸들러, 스타일, className 등 다양한 속성을 처리해야 합니다. */ export const setDomProps = (dom: HTMLElement, props: Record): void => { - // 여기를 구현하세요. + Object.keys(props).forEach((key) => { + if (key === "children") return; + if (key === "className") { + const normalizedClassName = normalizeClassName(props[key]); + if (normalizedClassName) { + dom.className = normalizedClassName; + } + } else if (key.startsWith("on")) { + const eventName = key.toLowerCase().substring(2); + if (props[key]) { + dom.removeEventListener(eventName, props[key]); + } + dom.addEventListener(eventName, props[key]); + } else if (key === "style") { + const styleObj = props[key]; + if (typeof styleObj === "object" && styleObj !== null) { + Object.keys(styleObj).forEach((styleProp) => { + dom.style[styleProp as any] = styleObj[styleProp]; + }); + } else if (typeof styleObj === "string") { + dom.setAttribute("style", styleObj); + } + } else { + const value = props[key]; + + if (BOOLEAN_ATTRIBUTES.includes(key as (typeof BOOLEAN_ATTRIBUTES)[number])) { + if (value) { + dom.setAttribute(key, ""); + (dom as any)[key] = true; + } else { + dom.removeAttribute(key); + (dom as any)[key] = false; + } + } else { + dom.setAttribute(key, value); + } + } + }); }; /** @@ -19,7 +83,69 @@ export const updateDomProps = ( prevProps: Record = {}, nextProps: Record = {}, ): void => { - // 여기를 구현하세요. + if (!dom || !(dom instanceof HTMLElement)) { + return; + } + + Object.keys(nextProps).forEach((key) => { + if (key === "children") return; + + if (prevProps[key] !== nextProps[key]) { + if (key === "className") { + const normalizedClassName = normalizeClassName(nextProps[key]); + dom.className = normalizedClassName || ""; + } else if (key.startsWith("on")) { + const eventName = key.toLowerCase().substring(2); + if (prevProps[key]) { + dom.removeEventListener(eventName, prevProps[key]); + } + dom.addEventListener(eventName, nextProps[key]); + } else if (key === "style") { + const styleObj = nextProps[key]; + if (typeof styleObj === "object" && styleObj !== null) { + Object.keys(styleObj).forEach((styleProp) => { + dom.style[styleProp as any] = styleObj[styleProp]; + }); + } else if (typeof styleObj === "string") { + dom.setAttribute("style", styleObj); + } + } else { + const value = nextProps[key]; + + if (BOOLEAN_ATTRIBUTES.includes(key as (typeof BOOLEAN_ATTRIBUTES)[number])) { + if (value) { + dom.setAttribute(key, ""); + (dom as any)[key] = true; + } else { + dom.removeAttribute(key); + (dom as any)[key] = false; + } + } else { + dom.setAttribute(key, value); + } + } + } + }); + + Object.keys(prevProps).forEach((key) => { + if (key === "children") return; + + if (!(key in nextProps)) { + if (key === "className") { + dom.className = ""; + } else if (key.startsWith("on")) { + const eventName = key.toLowerCase().substring(2); + dom.removeEventListener(eventName, prevProps[key]); + } else if (key === "style") { + dom.removeAttribute("style"); + } else if (BOOLEAN_ATTRIBUTES.includes(key as (typeof BOOLEAN_ATTRIBUTES)[number])) { + dom.removeAttribute(key); + (dom as any)[key] = false; + } else { + dom.removeAttribute(key); + } + } + }); }; /** @@ -27,7 +153,11 @@ export const updateDomProps = ( * Fragment나 컴포넌트 인스턴스는 여러 개의 DOM 노드를 가질 수 있습니다. */ export const getDomNodes = (instance: Instance | null): (HTMLElement | Text)[] => { - // 여기를 구현하세요. + if (!instance) return []; + if (instance.kind === NodeTypes.TEXT) return [instance.dom as Text]; + if (instance.kind === NodeTypes.FRAGMENT) return instance.children.map((child) => getDomNodes(child)).flat(); + if (instance.kind === NodeTypes.COMPONENT) return instance.children.map((child) => getDomNodes(child)).flat(); + if (instance.kind === NodeTypes.HOST) return [instance.dom as HTMLElement]; return []; }; @@ -35,7 +165,21 @@ export const getDomNodes = (instance: Instance | null): (HTMLElement | Text)[] = * 주어진 인스턴스에서 첫 번째 실제 DOM 노드를 찾습니다. */ export const getFirstDom = (instance: Instance | null): HTMLElement | Text | null => { - // 여기를 구현하세요. + if (!instance) return null; + + if (instance.kind === NodeTypes.TEXT || instance.kind === NodeTypes.HOST) { + return instance.dom as HTMLElement | Text; + } + + if (instance.kind === NodeTypes.FRAGMENT || instance.kind === NodeTypes.COMPONENT) { + for (const child of instance.children) { + const dom = getFirstDom(child); + if (dom) { + return dom; + } + } + } + return null; }; @@ -44,7 +188,8 @@ export const getFirstDom = (instance: Instance | null): HTMLElement | Text | nul */ export const getFirstDomFromChildren = (children: (Instance | null)[]): HTMLElement | Text | null => { // 여기를 구현하세요. - return null; + if (!children.length) return null; + return getFirstDom(children[0]); }; /** @@ -57,11 +202,131 @@ export const insertInstance = ( anchor: HTMLElement | Text | null = null, ): void => { // 여기를 구현하세요. + if (!instance) return; + + if (instance.kind === NodeTypes.FRAGMENT || instance.kind === NodeTypes.COMPONENT) { + let currentAnchor = anchor; + for (let i = instance.children.length - 1; i >= 0; i--) { + insertInstance(parentDom, instance.children[i], currentAnchor); + currentAnchor = getFirstDom(instance.children[i]); + } + return; + } + + if (!instance.dom) return; + + // Only use anchor if it's actually a child of parentDom + const useAnchor = anchor && anchor.parentNode === parentDom ? anchor : null; + + if (useAnchor) { + parentDom.insertBefore(instance.dom as HTMLElement, useAnchor); + } else { + parentDom.appendChild(instance.dom as HTMLElement); + } + + instance.children.forEach((child) => insertInstance(instance.dom as HTMLElement, child)); }; /** * 부모 DOM에서 인스턴스에 해당하는 모든 DOM 노드를 제거합니다. */ export const removeInstance = (parentDom: HTMLElement, instance: Instance | null): void => { - // 여기를 구현하세요. + if (!instance) { + while (parentDom.firstChild) { + parentDom.removeChild(parentDom.firstChild); + } + return; + } + + if (!instance.dom) { + instance.children.forEach((child) => removeInstance(parentDom, child)); + return; + } + + // Real DOM 제거 + if (instance.dom.parentNode === parentDom) { + parentDom.removeChild(instance.dom); + } + + // VDOM에서 real dom과 VNode 제거 + instance.dom = null; + instance.children = []; +}; + +export const createInstance = (node: VNode, path: string): Instance => { + if (node.type === TEXT_ELEMENT) { + return { + kind: NodeTypes.TEXT, + dom: document.createTextNode((node.props as { nodeValue: string }).nodeValue), + node, + children: [], + key: null, + path, + }; + } + if (node.type === Fragment) { + const instance: Instance = { + kind: NodeTypes.FRAGMENT, + dom: null, + node, + children: [], + key: node.key ?? null, + path, + }; + + instance.children = + node.props.children + ?.map((child, index) => { + const childPath = createChildPath(path, child.key, index, child.type, node.props.children); + return createInstance(child, childPath); + }) + .filter((child) => child !== null) ?? []; + + return instance; + } + if (typeof node.type === "function") { + const instance: Instance = { + kind: NodeTypes.COMPONENT, + dom: null, + node, + children: [], + key: node.key ?? null, + path, + }; + + const ComponentFunction = node.type as React.ComponentType; + const renderedNode = hookManager.runComponent(path, ComponentFunction, node.props); + + if (renderedNode) { + const childPath = createChildPath(path, renderedNode.key, 0, renderedNode.type, [renderedNode]); + const childInstance = createInstance(renderedNode, childPath); + if (childInstance) { + instance.children = [childInstance]; + } + } + + return instance; + } + + const dom = document.createElement(node.type as string); + setDomProps(dom, node.props); + + const instance: Instance = { + kind: NodeTypes.HOST, + dom, + node, + children: [], + key: node.key ?? null, + path, + }; + + node.props.children?.forEach((child, index) => { + const childPath = createChildPath(path, child.key, index, child.type, node.props.children); + const childInstance = createInstance(child, childPath); + if (childInstance) { + instance.children.push(childInstance); + } + }); + + return instance; }; diff --git a/packages/react/src/core/elements.ts b/packages/react/src/core/elements.ts index d04bce98..4db7b98b 100644 --- a/packages/react/src/core/elements.ts +++ b/packages/react/src/core/elements.ts @@ -6,32 +6,73 @@ import { Fragment, TEXT_ELEMENT } from "./constants"; /** * 주어진 노드를 VNode 형식으로 정규화합니다. * null, undefined, boolean, 배열, 원시 타입 등을 처리하여 일관된 VNode 구조를 보장합니다. + * */ -export const normalizeNode = (node: VNode): VNode | null => { - // 여기를 구현하세요. - return null; +export const normalizeNode = (node: unknown): VNode | null => { + if (isEmptyValue(node)) return null; + if (typeof node === "string" || typeof node === "number") { + return createTextElement(String(node)); + } + return node as VNode; }; /** * 텍스트 노드를 위한 VNode를 생성합니다. */ -const createTextElement = (node: VNode): VNode => { - // 여기를 구현하세요. - return {} as VNode; +const createTextElement = (node: string): VNode => { + return { + type: TEXT_ELEMENT, + key: null, + props: { + nodeValue: node, + children: [], + }, + }; }; /** * JSX로부터 전달된 인자를 VNode 객체로 변환합니다. * 이 함수는 JSX 변환기에 의해 호출됩니다. (예: Babel, TypeScript) */ + export const createElement = ( type: string | symbol | React.ComponentType, originProps?: Record | null, ...rawChildren: any[] -) => { - // 여기를 구현하세요. +): VNode => { + const flattenedChildren = flattenChildren(rawChildren); + const children = flattenedChildren.map((child) => normalizeNode(child)).filter((child) => child !== null); + + if (type === Fragment) { + return { + type: Fragment, + key: null, + props: { + children: children.length > 0 ? children : undefined, + }, + }; + } + + const { key: maybeKey, ...props } = originProps ?? {}; + + return { + type, + key: maybeKey ?? null, + props: { + ...props, + children: children.length > 0 ? children : undefined, + }, + } as VNode; }; +export const flattenChildren = (children: any[]): unknown[] => { + return children.reduce((acc, child) => { + if (Array.isArray(child)) { + return acc.concat(flattenChildren(child)); + } + return acc.concat(child); + }, []); +}; /** * 부모 경로와 자식의 key/index를 기반으로 고유한 경로를 생성합니다. * 이는 훅의 상태를 유지하고 Reconciliation에서 컴포넌트를 식별하는 데 사용됩니다. @@ -43,6 +84,29 @@ export const createChildPath = ( nodeType?: string | symbol | React.ComponentType, siblings?: VNode[], ): string => { - // 여기를 구현하세요. - return ""; + if (key) { + const duplicatedKeys = siblings?.filter((sibling) => sibling.key === key); + if (duplicatedKeys && duplicatedKeys.length > 1) { + console.warn(`Duplicate key ${key} found in siblings`); + } + + return `${parentPath}.key:${key}`; + } + + if (nodeType && siblings) { + const sameTypeIndex = siblings.slice(0, index).filter((s) => s.type === nodeType).length; + + let typeName: string; + if (typeof nodeType === "string") { + typeName = nodeType; + } else if (typeof nodeType === "symbol") { + typeName = nodeType.description || "Symbol"; + } else { + typeName = (nodeType as React.ComponentType).name || "Component"; + } + + return `${parentPath}.${typeName}:${sameTypeIndex}`; + } + + return `${parentPath}.${index}`; }; diff --git a/packages/react/src/core/hookManager.ts b/packages/react/src/core/hookManager.ts new file mode 100644 index 00000000..ed09939b --- /dev/null +++ b/packages/react/src/core/hookManager.ts @@ -0,0 +1,22 @@ +import { runtimeContext } from "./context"; + +export const hookManager = { + runComponent

= Record>( + path: string, + componentFunction: React.ComponentType

, + props: P, + ) { + runtimeContext.visited.add(path); + runtimeContext.componentStack.push(path); + + runtimeContext.cursor.path = path; + runtimeContext.cursor.index = 0; + + try { + return componentFunction(props); + } finally { + runtimeContext.componentStack.pop(); + runtimeContext.cursor.path = runtimeContext.componentStack[runtimeContext.componentStack.length - 1] ?? null; + } + }, +}; diff --git a/packages/react/src/core/hooks.ts b/packages/react/src/core/hooks.ts index ef35d0f6..7874444e 100644 --- a/packages/react/src/core/hooks.ts +++ b/packages/react/src/core/hooks.ts @@ -1,42 +1,103 @@ -import { shallowEquals, withEnqueue } from "../utils"; -import { context } from "./context"; -import { EffectHook } from "./types"; import { enqueueRender } from "./render"; -import { HookTypes } from "./constants"; +import { storeContext, runtimeContext } from "./context"; +import { StateHook, EffectHook } from "./types"; +import { shallowEquals } from "../utils"; -/** - * 사용되지 않는 컴포넌트의 훅 상태와 이펙트 클린업 함수를 정리합니다. - */ -export const cleanupUnusedHooks = () => { - // 여기를 구현하세요. +const getCurrentHook = () => { + const path = runtimeContext.cursor.path; + if (!path) throw new Error("Hooks must be called within a component"); + + const index = runtimeContext.cursor.index; + const hooks = storeContext.hooks.get(path) || []; + + if (!storeContext.hooks.has(path)) { + storeContext.hooks.set(path, hooks); + } + + return { + hooks, + currentHook: hooks[index] as StateHook | EffectHook | undefined, + index, + path, + }; }; -/** - * 컴포넌트의 상태를 관리하기 위한 훅입니다. - * @param initialValue - 초기 상태 값 또는 초기 상태를 반환하는 함수 - * @returns [현재 상태, 상태를 업데이트하는 함수] - */ export const useState = (initialValue: T | (() => T)): [T, (nextValue: T | ((prev: T) => T)) => void] => { - // 여기를 구현하세요. - // 1. 현재 컴포넌트의 훅 커서와 상태 배열을 가져옵니다. - // 2. 첫 렌더링이라면 초기값으로 상태를 설정합니다. - // 3. 상태 변경 함수(setter)를 생성합니다. - // - 새 값이 이전 값과 같으면(Object.is) 재렌더링을 건너뜁니다. - // - 값이 다르면 상태를 업데이트하고 재렌더링을 예약(enqueueRender)합니다. - // 4. 훅 커서를 증가시키고 [상태, setter]를 반환합니다. - const setState = (nextValue: T | ((prev: T) => T)) => {}; - return [initialValue as T, setState]; + const { hooks, currentHook, index } = getCurrentHook(); + + if (!currentHook) { + const initialState = typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + const newHook: StateHook = { + tag: "STATE", + state: initialState, + }; + hooks[index] = newHook; + } else if (currentHook.tag !== "STATE") { + throw new Error("Hook order mismatch: Expected STATE but got " + currentHook.tag); + } + + const hook = hooks[index] as StateHook; + const state = hook.state; + + const setState = (nextValue: T | ((prev: T) => T)) => { + const newState = typeof nextValue === "function" ? (nextValue as (prev: T) => T)(hook.state) : nextValue; + + if (Object.is(newState, hook.state)) return; + + hook.state = newState; + enqueueRender(); + }; + + runtimeContext.cursor.index++; + return [state, setState]; }; -/** - * 컴포넌트의 사이드 이펙트를 처리하기 위한 훅입니다. - * @param effect - 실행할 이펙트 함수. 클린업 함수를 반환할 수 있습니다. - * @param deps - 의존성 배열. 이 값들이 변경될 때만 이펙트가 다시 실행됩니다. - */ export const useEffect = (effect: () => (() => void) | void, deps?: unknown[]): void => { - // 여기를 구현하세요. - // 1. 이전 훅의 의존성 배열과 현재 의존성 배열을 비교(shallowEquals)합니다. - // 2. 의존성이 변경되었거나 첫 렌더링일 경우, 이펙트 실행을 예약합니다. - // 3. 이펙트 실행 전, 이전 클린업 함수가 있다면 먼저 실행합니다. - // 4. 예약된 이펙트는 렌더링이 끝난 후 비동기로 실행됩니다. + const { hooks, currentHook, index, path } = getCurrentHook(); + + const prevDeps = currentHook?.tag === "EFFECT" ? currentHook.deps : null; + const shouldRun = !currentHook || !deps || !prevDeps || !shallowEquals(prevDeps, deps); + + if (!currentHook) { + const newHook: EffectHook = { + tag: "EFFECT", + path, + deps: deps ?? null, + cleanup: null, + effect, + }; + hooks[index] = newHook; + if (shouldRun) { + runtimeContext.workQueue.passiveEffects.push(newHook); + } + } else if (currentHook.tag !== "EFFECT") { + throw new Error("Hook order mismatch: Expected EFFECT but got " + currentHook.tag); + } else { + // Update existing hook + const hook = currentHook as EffectHook; + hook.deps = deps ?? null; + hook.effect = effect; + + if (shouldRun) { + runtimeContext.workQueue.passiveEffects.push(hook); + } + } + + runtimeContext.cursor.index++; +}; + +export const cleanupUnusedHooks = () => { + for (const [path, hooks] of storeContext.hooks) { + if (!runtimeContext.visited.has(path)) { + // queue cleanup functions before deleting the hook + hooks.forEach((hook) => { + if (hook.tag === "EFFECT" && hook.cleanup) { + runtimeContext.workQueue.cleanups.push(hook.cleanup); + } + }); + + storeContext.hooks.delete(path); + storeContext.cleanupEffects.delete(path); + } + } }; diff --git a/packages/react/src/core/reconciler.ts b/packages/react/src/core/reconciler.ts index 12cbdd39..5256bb9c 100644 --- a/packages/react/src/core/reconciler.ts +++ b/packages/react/src/core/reconciler.ts @@ -1,16 +1,9 @@ -import { context } from "./context"; -import { Fragment, NodeTypes, TEXT_ELEMENT } from "./constants"; +import { runtimeContext } from "./context"; import { Instance, VNode } from "./types"; -import { - getFirstDom, - getFirstDomFromChildren, - insertInstance, - removeInstance, - setDomProps, - updateDomProps, -} from "./dom"; +import { createInstance, getFirstDom } from "./dom"; import { createChildPath } from "./elements"; -import { isEmptyValue } from "../utils"; +import { hookManager } from "./hookManager"; +import { NodeTypes } from "./constants"; /** * 이전 인스턴스와 새로운 VNode를 비교하여 DOM을 업데이트하는 재조정 과정을 수행합니다. @@ -19,6 +12,7 @@ import { isEmptyValue } from "../utils"; * @param instance - 이전 렌더링의 인스턴스 * @param node - 새로운 VNode * @param path - 현재 노드의 고유 경로 + * @param anchor - 이 노드가 삽입될 위치의 다음 형제 DOM (null이면 마지막에 추가) * @returns 업데이트되거나 새로 생성된 인스턴스 */ export const reconcile = ( @@ -26,13 +20,172 @@ export const reconcile = ( instance: Instance | null, node: VNode | null, path: string, + anchor: HTMLElement | Text | null = null, ): Instance | null => { - // 여기를 구현하세요. - // 1. 새 노드가 null이면 기존 인스턴스를 제거합니다. (unmount) - // 2. 기존 인스턴스가 없으면 새 노드를 마운트합니다. (mount) - // 3. 타입이나 키가 다르면 기존 인스턴스를 제거하고 새로 마운트합니다. - // 4. 타입과 키가 같으면 인스턴스를 업데이트합니다. (update) - // - DOM 요소: updateDomProps로 속성 업데이트 후 자식 재조정 - // - 컴포넌트: 컴포넌트 함수 재실행 후 자식 재조정 - return null; + // 1. Unmount + if (!node) { + if (instance) handleUnmount(parentDom, instance); + return null; + } + + // 2. Mount + if (!instance) { + return handleMount(parentDom, node, path, anchor); + } + + // 3. Replace + if (instance.node.type !== node.type || instance.key !== node.key) { + return handleReplace(parentDom, instance, node, path, anchor); + } + + // 4. Update + return handleUpdate(parentDom, instance, node, path, anchor); +}; + +const handleUnmount = (parentDom: HTMLElement, instance: Instance) => { + runtimeContext.workQueue.domMutations.push({ type: "REMOVE", instance, parentDOM: parentDom }); +}; + +const handleMount = ( + parentDom: HTMLElement, + node: VNode, + path: string, + anchor: HTMLElement | Text | null, +): Instance => { + const newInstance = createInstance(node, path); + runtimeContext.workQueue.domMutations.push({ type: "INSERT", instance: newInstance, parentDOM: parentDom, anchor }); + return newInstance; +}; + +const handleReplace = ( + parentDom: HTMLElement, + instance: Instance, + node: VNode, + path: string, + anchor: HTMLElement | Text | null, +): Instance => { + handleUnmount(parentDom, instance); + const newPath = createChildPath(path, node.key, 0, node.type); + return handleMount(parentDom, node, newPath, anchor); +}; + +const handleUpdate = ( + parentDom: HTMLElement, + instance: Instance, + node: VNode, + path: string, + anchor: HTMLElement | Text | null, +): Instance => { + const prevProps = instance.node.props; + instance.node = node; + + if (!instance.key) { + instance.path = path; + } + + // 1. Props Update + if (instance.kind === NodeTypes.TEXT && instance.dom instanceof Text) { + const nextText = node.props.nodeValue as string; + if (instance.dom.nodeValue !== nextText) { + runtimeContext.workQueue.domMutations.push({ + type: "UPDATE_TEXT", + dom: instance.dom, + prevText: instance.dom.nodeValue || "", + nextText, + }); + } + } else if (instance.kind === NodeTypes.HOST && instance.dom instanceof HTMLElement) { + if (prevProps !== node.props) { + runtimeContext.workQueue.domMutations.push({ + type: "UPDATE_PROPS", + dom: instance.dom, + prevProps, + nextProps: node.props, + }); + } + } + + // 2. Children Reconcile + updateChildren(parentDom, instance, node, instance.path); + + // 3. Reordering (position correction) + runtimeContext.workQueue.domMutations.push({ + type: "INSERT", + instance, + parentDOM: parentDom, + anchor, + }); + + return instance; +}; + +const updateChildren = (parentDom: HTMLElement, instance: Instance, node: VNode, path: string) => { + let nextChildrenVNodes: VNode[] = []; + + if (instance.kind === NodeTypes.COMPONENT) { + const Component = node.type as React.ComponentType; + const renderedNode = hookManager.runComponent(path, Component, node.props); + nextChildrenVNodes = renderedNode ? [renderedNode] : []; + } else { + nextChildrenVNodes = node.props.children ?? []; + } + + const childParentDom = instance.kind === NodeTypes.HOST ? (instance.dom as HTMLElement) : parentDom; + + instance.children = reconcileChildren(childParentDom, instance.children as Instance[], nextChildrenVNodes, path); +}; + +const reconcileChildren = ( + parentDom: HTMLElement, + oldChildren: Instance[], + newVNodes: VNode[], + parentPath: string, +): Instance[] => { + const oldChildrenMap = new Map(); + oldChildren.forEach((child) => { + if (child.key) oldChildrenMap.set(child.key, child); + }); + + const usedOldChildren = new Set(); + + const newChildren = newVNodes.map((node, index) => { + const childPath = createChildPath(parentPath, node.key, index, node.type, newVNodes); + let oldChild: Instance | null = null; + + if (node.key) { + oldChild = oldChildrenMap.get(node.key) ?? null; + } else { + const candidate = oldChildren[index]; + if (candidate && candidate.node.type === node.type) { + oldChild = candidate; + } else { + oldChild = oldChildren.find((c) => !usedOldChildren.has(c) && c.node.type === node.type) ?? null; + } + } + + if (oldChild) usedOldChildren.add(oldChild); + return { node, childPath, oldChild }; + }); + + let anchor: HTMLElement | Text | null = null; + const reconciledChildren: Instance[] = []; + + for (let i = newChildren.length - 1; i >= 0; i--) { + const { node, childPath, oldChild } = newChildren[i]; + const reconciledChild = reconcile(parentDom, oldChild, node, childPath, anchor); + + if (reconciledChild) { + reconciledChildren.unshift(reconciledChild); + const childDom = getFirstDom(reconciledChild); + if (childDom) anchor = childDom; + } + } + + oldChildren.forEach((oldChild) => { + if (!usedOldChildren.has(oldChild)) { + handleUnmount(parentDom, oldChild); + } + }); + + return reconciledChildren; }; diff --git a/packages/react/src/core/render.ts b/packages/react/src/core/render.ts index 79c4bbb8..68fb79f8 100644 --- a/packages/react/src/core/render.ts +++ b/packages/react/src/core/render.ts @@ -1,21 +1,36 @@ -import { context } from "./context"; -import { getDomNodes, insertInstance } from "./dom"; +import { storeContext, runtimeContext, resetRuntime } from "./context"; import { reconcile } from "./reconciler"; import { cleanupUnusedHooks } from "./hooks"; -import { withEnqueue } from "../utils"; +import { commitMutations } from "./commit"; +import { flushPassiveEffects } from "./scheduler"; +import { withEnqueue, enqueue } from "../utils"; + +const ROOT_PATH = "root"; -/** - * 루트 컴포넌트의 렌더링을 수행하는 함수입니다. - * `enqueueRender`에 의해 스케줄링되어 호출됩니다. - */ export const render = (): void => { - // 여기를 구현하세요. - // 1. 훅 컨텍스트를 초기화합니다. - // 2. reconcile 함수를 호출하여 루트 노드를 재조정합니다. - // 3. 사용되지 않은 훅들을 정리(cleanupUnusedHooks)합니다. + // reset runtime context + resetRuntime(); + + // reconcile + const oldInstance = storeContext.root.instance; + const newInstance = reconcile( + storeContext.root.container as HTMLElement, + oldInstance, + storeContext.root.node, + ROOT_PATH, + ); + storeContext.root.instance = newInstance; + + // Unused Hooks Cleanup + cleanupUnusedHooks(); + + // commit mutations + commitMutations(runtimeContext.workQueue.domMutations); + + // flush passive effects + enqueue(() => { + flushPassiveEffects(runtimeContext.workQueue.passiveEffects, runtimeContext.workQueue.cleanups); + }); }; -/** - * `render` 함수를 마이크로태스크 큐에 추가하여 중복 실행을 방지합니다. - */ export const enqueueRender = withEnqueue(render); diff --git a/packages/react/src/core/scheduler.ts b/packages/react/src/core/scheduler.ts new file mode 100644 index 00000000..a336cd81 --- /dev/null +++ b/packages/react/src/core/scheduler.ts @@ -0,0 +1,35 @@ +import { storeContext } from "./context"; +import { EffectHook } from "./types"; + +export const flushPassiveEffects = (newEffects: EffectHook[], unmountCleanups: (() => void)[]) => { + // unmount components + unmountCleanups.forEach((cleanup) => cleanup()); + + // execute previous cleanup functions + newEffects.forEach((effect) => { + // execute previous cleanup functions + const cleanups = storeContext.cleanupEffects.get(effect.path); + if (cleanups) { + cleanups.forEach((cleanup) => cleanup()); + storeContext.cleanupEffects.delete(effect.path); + } + + if (effect.cleanup) { + effect.cleanup(); + effect.cleanup = null; + } + }); + + newEffects.forEach((effect) => { + try { + const cleanupFn = effect.effect(); + if (typeof cleanupFn === "function") { + effect.cleanup = cleanupFn; + } else { + effect.cleanup = null; + } + } catch (e) { + console.error(e); + } + }); +}; diff --git a/packages/react/src/core/setup.ts b/packages/react/src/core/setup.ts index 03813995..cbefb8c6 100644 --- a/packages/react/src/core/setup.ts +++ b/packages/react/src/core/setup.ts @@ -1,19 +1,23 @@ -import { context } from "./context"; +import { storeContext } from "./context"; import { VNode } from "./types"; import { removeInstance } from "./dom"; -import { cleanupUnusedHooks } from "./hooks"; import { render } from "./render"; -/** - * Mini-React 애플리케이션의 루트를 설정하고 첫 렌더링을 시작합니다. - * - * @param rootNode - 렌더링할 최상위 VNode - * @param container - VNode가 렌더링될 DOM 컨테이너 - */ export const setup = (rootNode: VNode | null, container: HTMLElement): void => { - // 여기를 구현하세요. - // 1. 컨테이너 유효성을 검사합니다. - // 2. 이전 렌더링 내용을 정리하고 컨테이너를 비웁니다. - // 3. 루트 컨텍스트와 훅 컨텍스트를 리셋합니다. - // 4. 첫 렌더링을 실행합니다. + if (!container) throw new Error("Container is not found"); + if (!rootNode) throw new Error("Root node is not found"); + + // clean up previous content + removeInstance(container, storeContext.root.instance); + + // initialize storeContext + storeContext.root.container = container; + storeContext.root.node = rootNode; + storeContext.root.instance = null; + + storeContext.hooks.clear(); + storeContext.cleanupEffects.clear(); + + // first render + render(); }; diff --git a/packages/react/src/core/types.ts b/packages/react/src/core/types.ts index d88c5714..505cbca1 100644 --- a/packages/react/src/core/types.ts +++ b/packages/react/src/core/types.ts @@ -1,4 +1,4 @@ -import type { HookType, NodeType } from "./constants"; +import type { NodeType } from "./constants"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type Props = Record & { children?: VNode[] }; @@ -23,46 +23,62 @@ export interface Instance { path: string; } -export interface EffectHook { - kind: HookType["EFFECT"]; +// --- Hooks Types (Single Array Structure) --- + +export interface BaseHook { + tag: string; +} + +export interface StateHook extends BaseHook { + tag: "STATE"; + state: T; +} + +export interface EffectHook extends BaseHook { + tag: "EFFECT"; + path: string; deps: unknown[] | null; cleanup: (() => void) | null; effect: () => (() => void) | void; } +export type Hook = StateHook | EffectHook; + +// --- Context Types --- + export interface RootContext { container: HTMLElement | null; node: VNode | null; instance: Instance | null; - - reset(options: { container: HTMLElement; node: VNode }): void; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type State = any; - -export interface HooksContext { - state: Map; - cursor: Map; - visited: Set; - componentStack: string[]; - - clear(): void; - - readonly currentPath: string; - readonly currentCursor: number; - readonly currentHooks: State[]; +// Persistent context +export interface StoreContext { + root: RootContext; + hooks: Map; + cleanupEffects: Map void)[]>; } -export interface EffectsContext { - queue: Array<{ path: string; cursor: number }>; +// Temporary context for each render +export interface RuntimeContext { + cursor: { + path: string | null; + index: number; + }; + workQueue: { + domMutations: DomEffect[]; + passiveEffects: EffectHook[]; + cleanups: (() => void)[]; + }; + componentStack: string[]; + visited: Set; } -export interface Context { - root: RootContext; - hooks: HooksContext; - effects: EffectsContext; -} +export type DomEffect = + | { type: "INSERT"; instance: Instance; parentDOM: HTMLElement; anchor?: Node | null } + | { type: "REMOVE"; instance: Instance | null; parentDOM: HTMLElement } + | { type: "UPDATE_PROPS"; dom: HTMLElement; prevProps: Props; nextProps: Props } + | { type: "UPDATE_TEXT"; dom: Text; prevText: string; nextText: string }; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace diff --git a/packages/react/src/hocs/memo.ts b/packages/react/src/hocs/memo.ts index 24569ce4..d2a7bc46 100644 --- a/packages/react/src/hocs/memo.ts +++ b/packages/react/src/hocs/memo.ts @@ -12,10 +12,12 @@ import { shallowEquals } from "../utils"; */ export function memo

(Component: FunctionComponent

, equals = shallowEquals) { const MemoizedComponent: FunctionComponent

= (props) => { - // 여기를 구현하세요. - // useRef를 사용하여 이전 props와 렌더링 결과를 저장해야 합니다. - // equals 함수로 이전 props와 현재 props를 비교하여 렌더링 여부를 결정합니다. - return Component(props); + const cached = useRef<{ props: P; vnode: VNode }>(null); + + if (!cached.current || !equals(cached.current.props, props)) { + cached.current = { props, vnode: Component(props) as VNode }; + } + return cached.current.vnode; }; 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..7a8ec4e1 100644 --- a/packages/react/src/hooks/useAutoCallback.ts +++ b/packages/react/src/hooks/useAutoCallback.ts @@ -11,5 +11,9 @@ import { useRef } from "./useRef"; export const useAutoCallback = (fn: T): T => { // 여기를 구현하세요. // useRef와 useCallback을 조합하여 구현해야 합니다. - return fn; + + const ref = useRef(fn); + ref.current = fn; + + return useCallback((...args: unknown[]) => ref.current?.(...args), []) as T; }; diff --git a/packages/react/src/hooks/useCallback.ts b/packages/react/src/hooks/useCallback.ts index c0043993..291128f5 100644 --- a/packages/react/src/hooks/useCallback.ts +++ b/packages/react/src/hooks/useCallback.ts @@ -9,8 +9,9 @@ import { useMemo } from "./useMemo"; * @param deps - 의존성 배열 * @returns 메모이제이션된 콜백 함수 */ -export const useCallback = any>(callback: T, deps: DependencyList): T => { +export const useCallback = unknown>(callback: T, deps: DependencyList): T => { // 여기를 구현하세요. // useMemo를 사용하여 구현할 수 있습니다. - return callback; + + return useMemo(() => callback, deps); }; diff --git a/packages/react/src/hooks/useDeepMemo.ts b/packages/react/src/hooks/useDeepMemo.ts index f968d05a..96b21834 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..3c3be190 100644 --- a/packages/react/src/hooks/useMemo.ts +++ b/packages/react/src/hooks/useMemo.ts @@ -15,5 +15,12 @@ export const useMemo = (factory: () => T, deps: DependencyList, equals = shal // 여기를 구현하세요. // useRef를 사용하여 이전 의존성 배열과 계산된 값을 저장해야 합니다. // equals 함수로 의존성을 비교하여 factory 함수를 재실행할지 결정합니다. - return factory(); + + const memoized = useRef<{ value: T; deps: DependencyList }>(null); + + if (!memoized.current || !equals(memoized.current.deps, deps)) { + memoized.current = { value: factory(), deps: deps }; + } + + return memoized.current.value; }; diff --git a/packages/react/src/hooks/useRef.ts b/packages/react/src/hooks/useRef.ts index d5521ca1..5423cf46 100644 --- a/packages/react/src/hooks/useRef.ts +++ b/packages/react/src/hooks/useRef.ts @@ -7,8 +7,7 @@ import { useState } from "../core"; * @param initialValue - ref 객체의 초기 .current 값 * @returns `{ current: T }` 형태의 ref 객체 */ -export const useRef = (initialValue: T): { current: T } => { - // 여기를 구현하세요. - // useState를 사용하여 ref 객체를 한 번만 생성하도록 해야 합니다. - return { current: initialValue }; +export const useRef = (initialValue: T | null): { current: T | null } => { + const [ref] = useState<{ current: T | null }>(() => ({ current: initialValue })); + return ref as { current: T | null }; }; diff --git a/packages/react/src/utils/enqueue.ts b/packages/react/src/utils/enqueue.ts index a4957d53..c696125f 100644 --- a/packages/react/src/utils/enqueue.ts +++ b/packages/react/src/utils/enqueue.ts @@ -6,6 +6,7 @@ import type { AnyFunction } from "../types"; */ export const enqueue = (callback: () => void) => { // 여기를 구현하세요. + queueMicrotask(callback); }; /** @@ -15,5 +16,14 @@ export const enqueue = (callback: () => void) => { export const withEnqueue = (fn: AnyFunction) => { // 여기를 구현하세요. // scheduled 플래그를 사용하여 fn이 한 번만 예약되도록 구현합니다. - return () => {}; + let scheduled = false; + const scheduledFn = () => { + if (scheduled) return; + scheduled = true; + queueMicrotask(() => { + scheduled = false; + fn(); + }); + }; + return () => scheduledFn(); }; diff --git a/packages/react/src/utils/equals.ts b/packages/react/src/utils/equals.ts index 31ec4ba5..3ee19542 100644 --- a/packages/react/src/utils/equals.ts +++ b/packages/react/src/utils/equals.ts @@ -5,7 +5,16 @@ export const shallowEquals = (a: unknown, b: unknown): boolean => { // 여기를 구현하세요. // Object.is(), Array.isArray(), Object.keys() 등을 활용하여 1단계 깊이의 비교를 구현합니다. - return a === b; + if (a === b) return true; + if (typeof a !== typeof b) return false; + if (Object.is(a, b)) return true; + if (typeof a !== "object" || typeof b !== "object") return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((value, index) => value === b[index]); + } + if (a === null || b === null) return false; + if (Object.keys(a).length !== Object.keys(b).length) return false; + return Object.keys(a).every((key) => Object.is(a[key as keyof typeof a], b[key as keyof typeof b])); }; /** @@ -15,5 +24,14 @@ export const shallowEquals = (a: unknown, b: unknown): boolean => { export const deepEquals = (a: unknown, b: unknown): boolean => { // 여기를 구현하세요. // 재귀적으로 deepEquals를 호출하여 중첩된 구조를 비교해야 합니다. - return a === b; + if (a === b) return true; + if (typeof a !== typeof b) return false; + if (Object.is(a, b)) return true; + if (typeof a !== "object" || typeof b !== "object") return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((value, index) => deepEquals(value, b[index])); + } + if (a === null || b === null) return false; + if (Object.keys(a).length !== Object.keys(b).length) return false; + return Object.keys(a).every((key) => deepEquals(a[key as keyof typeof a], b[key as keyof typeof b])); }; diff --git a/packages/react/src/utils/validators.ts b/packages/react/src/utils/validators.ts index da81b3dd..7b57a57c 100644 --- a/packages/react/src/utils/validators.ts +++ b/packages/react/src/utils/validators.ts @@ -1,11 +1,11 @@ /** * VNode가 렌더링되지 않아야 하는 값인지 확인합니다. * (예: null, undefined, boolean) + * Empty Value: null, undefined, boolean, object({}) * * @param value - 확인할 값 * @returns 렌더링되지 않아야 하면 true, 그렇지 않으면 false */ export const isEmptyValue = (value: unknown): boolean => { - // 여기를 구현하세요. - return false; + return value === null || value === undefined || typeof value === "boolean"; };