Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
93 changes: 69 additions & 24 deletions packages/lib/src/Router.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createObserver } from "./createObserver";
import { isServer } from "./ssrUtils";
import type { AnyFunction, StringRecord } from "./types";

interface Route<Handler extends AnyFunction> {
Expand All @@ -11,39 +13,45 @@ interface Route<Handler extends AnyFunction> {
type QueryPayload = Record<string, string | number | undefined>;

export type RouterInstance<T extends AnyFunction> = InstanceType<typeof Router<T>>;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class Router<Handler extends (...args: any[]) => any> {
[x: string]: any;
readonly #routes: Map<string, Route<Handler>>;
readonly #observer = createObserver();
readonly #baseUrl;

#route: null | (Route<Handler> & { params: StringRecord; path: string });
readonly #isServer: boolean;

constructor(baseUrl = "") {
this.#routes = new Map();
this.#route = null;
this.#baseUrl = baseUrl.replace(/\/$/, "");

window.addEventListener("popstate", () => {
this.#route = this.#findRoute();
this.#observer.notify();
});

document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (!target?.closest("[data-link]")) {
return;
}
e.preventDefault();
const url = target.getAttribute("href") ?? target.closest("[data-link]")?.getAttribute("href");
if (url) {
this.push(url);
}
});
this.#isServer = isServer;

if (!this.#isServer) {
window.addEventListener("popstate", () => {
this.#route = this.#findRoute();
this.#observer.notify();
});

document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (!target?.closest("[data-link]")) {
return;
}
e.preventDefault();
const url = target.getAttribute("href") ?? target.closest("[data-link]")?.getAttribute("href");
if (url) {
this.push(url);
}
});
}
}

get query(): StringRecord {
if (this.#isServer) {
return {};
}
return Router.parseQuery(window.location.search);
}

Expand All @@ -67,7 +75,17 @@ export class Router<Handler extends (...args: any[]) => any> {
readonly subscribe = this.#observer.subscribe;

addRoute(path: string, handler: Handler) {
// 경로 패턴을 정규식으로 변환
// '*' 전용 처리
if (path === "*" || path === "/*") {
this.#routes.set(path, {
regex: new RegExp(`^${this.#baseUrl}.*$`),
paramNames: [],
handler,
});
return;
}

// 일반적인 파라미터(:id) 처리
const paramNames: string[] = [];
const regexPath = path
.replace(/:\w+/g, (match) => {
Expand All @@ -85,8 +103,22 @@ export class Router<Handler extends (...args: any[]) => any> {
});
}

#findRoute(url = window.location.pathname) {
const { pathname } = new URL(url, window.location.origin);
#findRoute(url?: string) {
if (this.#isServer) {
// SSR에서는 기본 라우트 반환
const defaultRoute = this.#routes.get("/");
if (defaultRoute) {
return {
...defaultRoute,
params: {},
path: "/",
};
}
return null;
}

const actualUrl = url || window.location.pathname;
const { pathname } = new URL(actualUrl, window.location.origin);
for (const [routePath, route] of this.#routes) {
const match = pathname.match(route.regex);
if (match) {
Expand All @@ -107,6 +139,10 @@ export class Router<Handler extends (...args: any[]) => any> {
}

push(url: string) {
if (this.#isServer) {
return;
}

try {
// baseUrl이 없으면 자동으로 붙여줌
const fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url);
Expand All @@ -130,8 +166,13 @@ export class Router<Handler extends (...args: any[]) => any> {
this.#observer.notify();
}

static parseQuery = (search = window.location.search) => {
const params = new URLSearchParams(search);
static parseQuery = (search?: string) => {
if (isServer) {
return {};
}

const actualSearch = search || window.location.search;
const params = new URLSearchParams(actualSearch);
const query: StringRecord = {};
for (const [key, value] of params) {
query[key] = value;
Expand All @@ -150,6 +191,10 @@ export class Router<Handler extends (...args: any[]) => any> {
};

static getUrl = (newQuery: QueryPayload, baseUrl = "") => {
if (isServer) {
return "/";
}

const currentQuery = Router.parseQuery();
const updatedQuery = { ...currentQuery, ...newQuery };

Expand Down
3 changes: 2 additions & 1 deletion packages/lib/src/createStorage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createObserver } from "./createObserver.ts";
import { safeLocalStorage } from "./ssrUtils.js";

export const createStorage = <T>(key: string, storage = window.localStorage) => {
export const createStorage = <T>(key: string, storage = safeLocalStorage) => {
let data: T | null = JSON.parse(storage.getItem(key) ?? "null");
const { subscribe, notify } = createObserver();

Expand Down
1 change: 1 addition & 0 deletions packages/lib/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from "./useShallowSelector";
export * from "./useShallowState";
export * from "./useStorage";
export * from "./useStore";
export * from "./useSSRSafeExternalStore";
17 changes: 15 additions & 2 deletions packages/lib/src/hooks/useRouter.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { RouterInstance } from "../Router";
import type { AnyFunction } from "../types";
import { useSyncExternalStore } from "react";
import { useShallowSelector } from "./useShallowSelector";

const defaultSelector = <T, S = T>(state: T) => state as unknown as S;

export const useRouter = <T extends RouterInstance<AnyFunction>, S>(router: T, selector = defaultSelector<T, S>) => {
export const useRouter = <T extends RouterInstance<AnyFunction>, S = T>(
router: T,
selector: (r: T) => S = defaultSelector as unknown as (r: T) => S,
) => {
const shallowSelector = useShallowSelector(selector);
return useSyncExternalStore(router.subscribe, () => shallowSelector(router));

// 라우터 인스턴스 자체를 읽는다 (getState 기대 X)
const getSnapshot = () => shallowSelector(router);

// subscribe는 필수. 없으면 no-op

const subscribe = typeof (router as any).subscribe === "function" ? (router as any).subscribe : () => () => {};

// ✅ SSR 호환: 3번째 인자(getServerSnapshot) 추가
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
};
10 changes: 10 additions & 0 deletions packages/lib/src/hooks/useSSRSafeExternalStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useSyncExternalStore } from "react";

/** SSR에서도 안전하게 동작: 세 번째 인자가 비면 getSnapshot을 재사용 */
export function useSSRSafeExternalStore<T>(
subscribe: (listener: () => void) => () => void,
getSnapshot: () => T,
getServerSnapshot?: () => T,
) {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot ?? getSnapshot);
}
3 changes: 2 additions & 1 deletion packages/lib/src/hooks/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ const defaultSelector = <T, S = T>(state: T) => state as unknown as S;

export const useStore = <T, S = T>(store: Store<T>, selector: (state: T) => S = defaultSelector<T, S>) => {
const shallowSelector = useShallowSelector(selector);
return useSyncExternalStore(store.subscribe, () => shallowSelector(store.getState()));
const getSnapshot = () => shallowSelector(store.getState());
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
};
1 change: 1 addition & 0 deletions packages/lib/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from "./Router";
export { useStore, useStorage, useRouter, useAutoCallback } from "./hooks";
export * from "./equals";
export * from "./types";
export * from "./";
66 changes: 66 additions & 0 deletions packages/lib/src/ssrUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* SSR 환경 체크 및 브라우저 전용 함수 래퍼
*/

export const isServer = typeof window === "undefined";
export const isBrowser = !isServer;

/**
* 브라우저에서만 실행되는 함수 래퍼
*/
export const clientOnly = (fn, fallback = () => {}) => {
return (...args) => {
if (isBrowser) {
return fn(...args);
}
return fallback(...args);
};
};

/**
* 서버에서만 실행되는 함수 래퍼
*/
export const serverOnly = (fn, fallback = () => {}) => {
return (...args) => {
if (isServer) {
return fn(...args);
}
return fallback(...args);
};
};

/**
* 안전한 localStorage 래퍼
*/
export const safeLocalStorage = {
getItem: clientOnly(
(key) => window.localStorage.getItem(key),
() => null,
),
setItem: clientOnly(
(key, value) => window.localStorage.setItem(key, value),
() => {},
),
removeItem: clientOnly(
(key) => window.localStorage.removeItem(key),
() => {},
),
};

/**
* 안전한 DOM 접근 래퍼
*/
export const safeDocument = {
getElementById: clientOnly(
(id) => document.getElementById(id),
() => null,
),
querySelector: clientOnly(
(selector) => document.querySelector(selector),
() => null,
),
addEventListener: clientOnly(
(event, handler) => document.addEventListener(event, handler),
() => {},
),
};
46 changes: 23 additions & 23 deletions packages/react/index.html
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--app-head-->
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/src/styles.css">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: "#3b82f6",
secondary: "#6b7280"
}
}
}
};
</script>
</head>
<body class="bg-gray-50">
<div id="root"><!--app-html--></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--app-head-->
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/src/styles.css" />
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: "#3b82f6",
secondary: "#6b7280",
},
},
},
};
</script>
</head>
<body class="bg-gray-50">
<div id="root"><!--app-html--></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Loading