diff --git a/packages/vanilla/index.html b/packages/vanilla/index.html index 483a6d5e..6af3e615 100644 --- a/packages/vanilla/index.html +++ b/packages/vanilla/index.html @@ -4,19 +4,20 @@ + - + diff --git a/packages/vanilla/server.js b/packages/vanilla/server.js index b9a56d98..6602e680 100644 --- a/packages/vanilla/server.js +++ b/packages/vanilla/server.js @@ -1,31 +1,67 @@ import express from "express"; +import fs from "fs"; const prod = process.env.NODE_ENV === "production"; const port = process.env.PORT || 5173; -const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/"); +// const base = process.env.BASE || (prod ? "/front_6th_chapter4-1/vanilla/" : "/"); +const base = process.env.BASE || "/front_6th_chapter4-1/vanilla/"; const app = express(); -const render = () => { - return `
안녕하세요
`; -}; - -app.get("*all", (req, res) => { - res.send( - ` - - - - - - Vanilla Javascript SSR - - -
${render()}
- - - `.trim(), - ); +// Cached production assets +const templateHtml = prod ? fs.readFileSync("./dist/vanilla/index.html", "utf-8") : ""; + +let vite; + +if (!prod) { + const { createServer } = await import("vite"); + vite = await createServer({ + server: { middlewareMode: true }, + appType: "custom", + base, + }); + app.use(vite.middlewares); +} else { + const compression = (await import("compression")).default; + const sirv = (await import("sirv")).default; + app.use(compression()); + app.use(base, sirv("./dist/vanilla", { extensions: [] })); +} + +app.use("*all", async (req, res) => { + try { + const url = req.originalUrl.replace(base, ""); + + /** @type {string} */ + let template; + /** @type {import('./src/main-server.js').render} */ + let render; + if (!prod) { + // Always read fresh template in development + template = fs.readFileSync("./index.html", "utf-8"); + template = await vite.transformIndexHtml(url, template); + render = (await vite.ssrLoadModule("/src/main-server.js")).render; + } else { + template = templateHtml; + render = (await import("./dist/vanilla-ssr/main-server.js")).render; + } + + const rendered = await render(url, req.query); + + const html = template + .replace(``, rendered.head ?? "") + .replace( + ``, + ``, + ) + .replace(``, rendered.html ?? ""); + + res.status(200).set({ "Content-Type": "text/html" }).send(html); + } catch (e) { + vite?.ssrFixStacktrace(e); + console.log(e.stack); + res.status(500).end(e.stack); + } }); // Start http server diff --git a/packages/vanilla/src/api/productApi.js b/packages/vanilla/src/api/productApi.js index c2330fbe..00303d2e 100644 --- a/packages/vanilla/src/api/productApi.js +++ b/packages/vanilla/src/api/productApi.js @@ -1,3 +1,13 @@ +const prod = process.env.NODE_ENV === "production"; + +const getBaseUrl = () => { + if (typeof window !== "undefined") { + return ""; + } + + return prod ? "http://localhost:4174" : "http://localhost:5174"; +}; + export async function getProducts(params = {}) { const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; const page = params.current ?? params.page ?? 1; @@ -11,17 +21,17 @@ export async function getProducts(params = {}) { sort, }); - const response = await fetch(`/api/products?${searchParams}`); + const response = await fetch(`${getBaseUrl()}/api/products?${searchParams}`); return await response.json(); } export async function getProduct(productId) { - const response = await fetch(`/api/products/${productId}`); + const response = await fetch(`${getBaseUrl()}/api/products/${productId}`); return await response.json(); } export async function getCategories() { - const response = await fetch("/api/categories"); + const response = await fetch(`${getBaseUrl()}/api/categories`); return await response.json(); } diff --git a/packages/vanilla/src/lib/Router.js b/packages/vanilla/src/lib/Router.js index 2238a878..d053718e 100644 --- a/packages/vanilla/src/lib/Router.js +++ b/packages/vanilla/src/lib/Router.js @@ -14,6 +14,10 @@ export class Router { this.#route = null; this.#baseUrl = baseUrl.replace(/\/$/, ""); + if (typeof window === "undefined") { + return; + } + window.addEventListener("popstate", () => { this.#route = this.#findRoute(); this.#observer.notify(); @@ -25,7 +29,7 @@ export class Router { } get query() { - return Router.parseQuery(window.location.search); + return Router.parseQuery(typeof window !== "undefined" ? window.location.search : {}); } set query(newQuery) { @@ -73,8 +77,8 @@ export class Router { }); } - #findRoute(url = window.location.pathname) { - const { pathname } = new URL(url, window.location.origin); + #findRoute(url = typeof window !== "undefined" ? window.location.pathname : "/") { + const { pathname } = new URL(url, typeof window !== "undefined" ? window.location.origin : "/"); for (const [routePath, route] of this.#routes) { const match = pathname.match(route.regex); if (match) { @@ -103,7 +107,9 @@ export class Router { // baseUrl이 없으면 자동으로 붙여줌 let fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url); - const prevFullUrl = `${window.location.pathname}${window.location.search}`; + const prevFullUrl = `${typeof window !== "undefined" ? window.location.pathname : "/"}${ + typeof window !== "undefined" ? window.location.search : "" + }`; // 히스토리 업데이트 if (prevFullUrl !== fullUrl) { @@ -130,7 +136,7 @@ export class Router { * @param {string} search - location.search 또는 쿼리 문자열 * @returns {Object} 파싱된 쿼리 객체 */ - static parseQuery = (search = window.location.search) => { + static parseQuery = (search = typeof window !== "undefined" ? window.location.search : "") => { const params = new URLSearchParams(search); const query = {}; for (const [key, value] of params) { @@ -166,6 +172,8 @@ export class Router { }); const queryString = Router.stringifyQuery(updatedQuery); - return `${baseUrl}${window.location.pathname.replace(baseUrl, "")}${queryString ? `?${queryString}` : ""}`; + return `${baseUrl}${typeof window !== "undefined" ? window.location.pathname.replace(baseUrl, "") : "/"}${ + queryString ? `?${queryString}` : "" + }`; }; } diff --git a/packages/vanilla/src/lib/ServerRouter.js b/packages/vanilla/src/lib/ServerRouter.js new file mode 100644 index 00000000..912cda4e --- /dev/null +++ b/packages/vanilla/src/lib/ServerRouter.js @@ -0,0 +1,158 @@ +/** + * Node.js 서버 라우터 + */ +import { createObserver } from "./createObserver.js"; + +export class ServerRouter { + #routes; + #route; + #observer = createObserver(); + #baseUrl; + #currentQuery; + + constructor(baseUrl = "") { + this.#routes = new Map(); + this.#route = null; + this.#baseUrl = baseUrl.replace(/\/$/, ""); + } + + get baseUrl() { + return this.#baseUrl; + } + + get query() { + return this.#currentQuery; + } + + set query(newQuery) { + const newUrl = ServerRouter.getUrl(newQuery, this.#baseUrl); + this.push(newUrl); + } + + get params() { + return this.#route?.params ?? {}; + } + + get route() { + return this.#route; + } + + get target() { + return this.#route?.handler; + } + + subscribe(fn) { + this.#observer.subscribe(fn); + } + + /** + * 라우트 등록 + * @param {string} path - 경로 패턴 (예: "/product/:id") + * @param {Function} handler - 라우트 핸들러 + */ + addRoute(path, handler) { + // 경로 패턴을 정규식으로 변환 + const paramNames = []; + const regexPath = path + .replace(/:\w+/g, (match) => { + paramNames.push(match.slice(1)); // ':id' -> 'id' + return "([^/]+)"; + }) + .replace(/\//g, "\\/"); + + const regex = new RegExp(`^${this.#baseUrl}${regexPath}$`); + + this.#routes.set(path, { + regex, + paramNames, + handler, + }); + } + + #findRoute(url = "/", origin = "http://localhost") { + const { pathname } = new URL(url, origin); + for (const [routePath, route] of this.#routes) { + const match = pathname.match(route.regex); + if (match) { + // 매치된 파라미터들을 객체로 변환 + const params = {}; + route.paramNames.forEach((name, index) => { + params[name] = match[index + 1]; + }); + + return { + ...route, + params, + path: routePath, + }; + } + } + return null; + } + + /** + * 네비게이션 실행 + * @param {string} url - 이동할 경로 + */ + push(url = "/") { + try { + this.#route = this.#findRoute(url); + } catch (error) { + console.error("라우터 네비게이션 오류:", error); + } + } + + /** + * 라우터 시작 + */ + start(url = "/", query = {}) { + this.#currentQuery = query; + this.#route = this.#findRoute(url); + this.#observer.notify(); + } + + /** + * 쿼리 파라미터를 객체로 파싱 + * @param {string} search - location.search 또는 쿼리 문자열 + * @returns {Object} 파싱된 쿼리 객체 + */ + static parseQuery = (search = "") => { + const params = new URLSearchParams(search); + const query = {}; + for (const [key, value] of params) { + query[key] = value; + } + return query; + }; + + /** + * 객체를 쿼리 문자열로 변환 + * @param {Object} query - 쿼리 객체 + * @returns {string} 쿼리 문자열 + */ + static stringifyQuery = (query) => { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== null && value !== undefined && value !== "") { + params.set(key, String(value)); + } + } + return params.toString(); + }; + + static getUrl = (newQuery, baseUrl = "") => { + const currentQuery = ServerRouter.parseQuery(); + const updatedQuery = { ...currentQuery, ...newQuery }; + + // 빈 값들 제거 + Object.keys(updatedQuery).forEach((key) => { + if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") { + delete updatedQuery[key]; + } + }); + + const queryString = ServerRouter.stringifyQuery(updatedQuery); + //TODO: 수정해야하나? + return `${baseUrl}/${queryString ? `?${queryString}` : ""}`; + }; +} diff --git a/packages/vanilla/src/lib/createStorage.js b/packages/vanilla/src/lib/createStorage.js index 08b504f2..31e4d6fd 100644 --- a/packages/vanilla/src/lib/createStorage.js +++ b/packages/vanilla/src/lib/createStorage.js @@ -1,10 +1,29 @@ +const createMemoryStorage = () => { + let value = {}; + return { + getItem: (key) => (key in value ? value[key] : null), + setItem: (key, value) => { + value[key] = value; + }, + removeItem: (key) => { + delete value[key]; + }, + clear: () => { + value = {}; + }, + }; +}; + /** * 로컬스토리지 추상화 함수 * @param {string} key - 스토리지 키 * @param {Storage} storage - 기본값은 localStorage * @returns {Object} { get, set, reset } */ -export const createStorage = (key, storage = window.localStorage) => { +export const createStorage = ( + key, + storage = typeof window !== "undefined" ? window.localStorage : createMemoryStorage(), +) => { const get = () => { try { const item = storage.getItem(key); diff --git a/packages/vanilla/src/lib/index.js b/packages/vanilla/src/lib/index.js index a598ef30..da63f1df 100644 --- a/packages/vanilla/src/lib/index.js +++ b/packages/vanilla/src/lib/index.js @@ -2,3 +2,4 @@ export * from "./createObserver"; export * from "./createStore"; export * from "./createStorage"; export * from "./Router"; +export * from "./ServerRouter"; diff --git a/packages/vanilla/src/main-server.js b/packages/vanilla/src/main-server.js index 40b58858..5c6d42ff 100644 --- a/packages/vanilla/src/main-server.js +++ b/packages/vanilla/src/main-server.js @@ -1,4 +1,136 @@ +import { HomePage, NotFoundPage, ProductDetailPage } from "./pages"; +import { router } from "./router"; +import { getProducts, getCategories, getProduct } from "./api/productApi.js"; +import { productStore } from "./stores"; +import { PRODUCT_ACTIONS } from "./stores/actionTypes"; + +router.addRoute("/", HomePage); +router.addRoute("/product/:id/", ProductDetailPage); +router.addRoute(".*", NotFoundPage); + export const render = async (url, query) => { - console.log({ url, query }); - return ""; + router.start(url, query); + const route = router.route; + if (!route) { + return { + head: "", + html: NotFoundPage(), + initialData: JSON.stringify({}), + }; + } + let head = ""; + let data; + + if (route.path === "/") { + try { + const [productsData, categories] = await Promise.all([getProducts(query), getCategories()]); + + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: productsData, + categories: categories, + totalCount: productsData.pagination.total || 0, + loading: false, + status: "done", + error: null, + currentProduct: null, + relatedProducts: [], + }, + }); + + head = ` + 쇼핑몰 - 홈 + + `; + data = { + products: productsData.products || [], + categories: categories || {}, + totalCount: productsData.pagination.total || 0, + }; + } catch (error) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + currentProduct: null, + relatedProducts: [], + loading: false, + status: "done", + error: error.message, + categories: {}, + totalCount: 0, + products: [], + }, + }); + + data = { + products: [], + categories: {}, + totalCount: 0, + }; + } + } else if (route.path === "/product/:id/") { + const productId = route.params.id; + + try { + const product = await getProduct(productId); + + let relatedProducts = []; + if (product && product.category2) { + relatedProducts = await getProducts({ category2: product.category2, limit: 20, page: 1 }).filter( + (p) => p.productId !== productId, + ); + } + + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + currentProduct: product, + relatedProducts: relatedProducts, + loading: false, + status: "done", + error: null, + categories: {}, + totalCount: 0, + products: [], + }, + }); + + head = ` + ${product.title} - 쇼핑몰 + + `; + + data = { + product: product, + relatedProducts: relatedProducts, + }; + } catch (error) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + currentProduct: null, + relatedProducts: [], + loading: false, + status: "done", + error: error.message, + categories: {}, + totalCount: 0, + products: [], + }, + }); + + data = { + products: [], + categories: {}, + totalCount: 0, + }; + } + } + + return { + head: head, + html: router.target(), + initialData: JSON.stringify(data), + }; }; diff --git a/packages/vanilla/src/main.js b/packages/vanilla/src/main.js index 4c3f2765..de910b25 100644 --- a/packages/vanilla/src/main.js +++ b/packages/vanilla/src/main.js @@ -4,6 +4,8 @@ import { registerAllEvents } from "./events"; import { loadCartFromStorage } from "./services"; import { router } from "./router"; import { BASE_URL } from "./constants.js"; +import { productStore } from "./stores"; +import { PRODUCT_ACTIONS } from "./stores/actionTypes"; const enableMocking = () => import("./mocks/browser.js").then(({ worker }) => @@ -15,10 +17,62 @@ const enableMocking = () => }), ); +//hydraion SSR Data from server +function hydrateFromSSRData() { + if (typeof window === "undefined" || !window.__INITIAL_DATA__) { + return; + } + + try { + const initialData = window.__INITIAL_DATA__; + + const currentPath = window.location.pathname; + + // 홈페이지 hydration + if (currentPath === "/" && initialData.products) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: initialData.products || [], + totalCount: initialData.totalCount || 0, + categories: initialData.categories || {}, + currentProduct: null, + relatedProducts: [], + loading: false, + error: null, + status: "done", + }, + }); + } + // 상품 상세 페이지 hydration + else if (currentPath.includes("/product/") && initialData.product) { + productStore.dispatch({ + type: PRODUCT_ACTIONS.SETUP, + payload: { + products: [], + totalCount: 0, + categories: {}, + currentProduct: initialData.product, + relatedProducts: initialData.relatedProducts || [], + loading: false, + error: null, + status: "done", + }, + }); + } + + // hydration 완료 + window.__HYDRATED__ = true; + } catch (error) { + console.error(error); + } +} + function main() { registerAllEvents(); registerGlobalEvents(); loadCartFromStorage(); + hydrateFromSSRData(); initRender(); router.start(); } diff --git a/packages/vanilla/src/pages/HomePage.js b/packages/vanilla/src/pages/HomePage.js index ca08c26c..5edc824a 100644 --- a/packages/vanilla/src/pages/HomePage.js +++ b/packages/vanilla/src/pages/HomePage.js @@ -7,7 +7,19 @@ import { PageWrapper } from "./PageWrapper.js"; export const HomePage = withLifecycle( { onMount: () => { - loadProductsAndCategories(); + if (typeof window !== "undefined") { + // Hydration된 데이터가 있으면 API 호출 스킵 + if (window.__HYDRATED__) { + const { products, categories, status } = productStore.getState(); + if (products.length > 0 && Object.keys(categories).length > 0 && status === "done") { + console.log("✅ Skip loading - data already hydrated from SSR"); + return; + } + } + + console.log("🔄 Loading data from client"); + loadProductsAndCategories(); + } }, watches: [ () => { diff --git a/packages/vanilla/src/pages/ProductDetailPage.js b/packages/vanilla/src/pages/ProductDetailPage.js index 73d0ec30..0bd552dc 100644 --- a/packages/vanilla/src/pages/ProductDetailPage.js +++ b/packages/vanilla/src/pages/ProductDetailPage.js @@ -237,7 +237,17 @@ function ProductDetail({ product, relatedProducts = [] }) { export const ProductDetailPage = withLifecycle( { onMount: () => { - loadProductDetailForPage(router.params.id); + if (typeof window !== "undefined") { + if (window.__HYDRATED__) { + const { currentProduct, status } = productStore.getState(); + const productId = router.params.id; + if (currentProduct && currentProduct.productId === productId && status === "done") { + return; + } + } + + loadProductDetailForPage(router.params.id); + } }, watches: [() => [router.params.id], () => loadProductDetailForPage(router.params.id)], }, diff --git a/packages/vanilla/src/router/router.js b/packages/vanilla/src/router/router.js index d897ee76..75e7ba3c 100644 --- a/packages/vanilla/src/router/router.js +++ b/packages/vanilla/src/router/router.js @@ -1,5 +1,5 @@ // 글로벌 라우터 인스턴스 -import { Router } from "../lib"; +import { Router, ServerRouter } from "../lib"; import { BASE_URL } from "../constants.js"; -export const router = new Router(BASE_URL); +export const router = typeof window !== "undefined" ? new Router(BASE_URL) : new ServerRouter(BASE_URL); diff --git a/packages/vanilla/src/router/withLifecycle.js b/packages/vanilla/src/router/withLifecycle.js index ccb21113..a179c787 100644 --- a/packages/vanilla/src/router/withLifecycle.js +++ b/packages/vanilla/src/router/withLifecycle.js @@ -31,10 +31,12 @@ const mount = (page) => { const lifecycle = getPageLifecycle(page); if (lifecycle.mounted) return; - // 마운트 콜백들 실행 - lifecycle.mount?.(); - lifecycle.mounted = true; - lifecycle.deps = []; + // 마운트 콜백들 실행: 서버사이드에서는 돔 추가 및 제거 시 이벤트 발생X + if (typeof window !== "undefined") { + lifecycle.mount?.(); + lifecycle.mounted = true; + lifecycle.deps = []; + } }; // 페이지 언마운트 처리 @@ -43,9 +45,11 @@ const unmount = (pageFunction) => { if (!lifecycle.mounted) return; - // 언마운트 콜백들 실행 - lifecycle.unmount?.(); - lifecycle.mounted = false; + // 언마운트 콜백들 실행: 서버사이드에서는 돔 추가 및 제거 시 이벤트 발생X + if (typeof window !== "undefined") { + lifecycle.unmount?.(); + lifecycle.mounted = false; + } }; export const withLifecycle = ({ onMount, onUnmount, watches } = {}, page) => { diff --git a/packages/vanilla/static-site-generate.js b/packages/vanilla/static-site-generate.js index c479f112..37fcc8e1 100644 --- a/packages/vanilla/static-site-generate.js +++ b/packages/vanilla/static-site-generate.js @@ -1,19 +1,20 @@ import fs from "fs"; - -const render = () => { - return `
안녕하세요
`; -}; +import { render } from "./dist/vanilla-ssr/main-server.js"; async function generateStaticSite() { - // HTML 템플릿 읽기 + // 임시. HTML 템플릿 읽기 const template = fs.readFileSync("../../dist/vanilla/index.html", "utf-8"); - // 어플리케이션 렌더링하기 - const appHtml = render(); + const rendered = await render("/", {}); + const html = template + .replace(``, rendered.head ?? "") + .replace( + ``, + ``, + ) + .replace(``, rendered.html ?? ""); - // 결과 HTML 생성하기 - const result = template.replace("", appHtml); - fs.writeFileSync("../../dist/vanilla/index.html", result); + fs.writeFileSync("../../dist/vanilla/index.html", html); } // 실행 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 766adcbc..fff88855 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,7 +101,7 @@ importers: version: 6.8.0 '@testing-library/react': specifier: latest - version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@testing-library/user-event': specifier: latest version: 14.6.1(@testing-library/dom@10.4.1) @@ -110,10 +110,10 @@ importers: version: 24.0.13 '@types/react': specifier: latest - version: 19.1.11 + version: 19.1.12 '@types/react-dom': specifier: latest - version: 19.1.7(@types/react@19.1.11) + version: 19.1.9(@types/react@19.1.12) '@types/use-sync-external-store': specifier: latest version: 1.5.0 @@ -177,16 +177,16 @@ importers: version: 24.0.13 '@types/react': specifier: latest - version: 19.1.11 + version: 19.1.12 '@types/react-dom': specifier: latest - version: 19.1.7(@types/react@19.1.11) + version: 19.1.9(@types/react@19.1.12) '@types/use-sync-external-store': specifier: latest version: 1.5.0 '@vitejs/plugin-react': specifier: latest - version: 5.0.1(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0)) + version: 5.0.2(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0)) compression: specifier: ^1.7.5 version: 1.8.1 @@ -1103,13 +1103,13 @@ packages: '@types/node@24.0.13': resolution: {integrity: sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==} - '@types/react-dom@19.1.7': - resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==} + '@types/react-dom@19.1.9': + resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==} peerDependencies: '@types/react': ^19.0.0 - '@types/react@19.1.11': - resolution: {integrity: sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==} + '@types/react@19.1.12': + resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==} '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -1191,8 +1191,8 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 || ^7 - '@vitejs/plugin-react@5.0.1': - resolution: {integrity: sha512-DE4UNaBXwtVoDJ0ccBdLVjFTWL70NRuWNCxEieTI3lrq9ORB9aOCQEKstwDXBl87NvFdbqh/p7eINGyj0BthJA==} + '@vitejs/plugin-react@5.0.2': + resolution: {integrity: sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -3701,15 +3701,15 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@babel/runtime': 7.27.6 '@testing-library/dom': 10.4.1 react: 19.1.1 react-dom: 19.1.1(react@19.1.1) optionalDependencies: - '@types/react': 19.1.11 - '@types/react-dom': 19.1.7(@types/react@19.1.11) + '@types/react': 19.1.12 + '@types/react-dom': 19.1.9(@types/react@19.1.12) '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': dependencies: @@ -3759,11 +3759,11 @@ snapshots: dependencies: undici-types: 7.8.0 - '@types/react-dom@19.1.7(@types/react@19.1.11)': + '@types/react-dom@19.1.9(@types/react@19.1.12)': dependencies: - '@types/react': 19.1.11 + '@types/react': 19.1.12 - '@types/react@19.1.11': + '@types/react@19.1.12': dependencies: csstype: 3.1.3 @@ -3878,12 +3878,12 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' - '@vitejs/plugin-react@5.0.1(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0))': + '@vitejs/plugin-react@5.0.2(rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0))': dependencies: '@babel/core': 7.28.3 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.3) '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.3) - '@rolldown/pluginutils': 1.0.0-beta.32 + '@rolldown/pluginutils': 1.0.0-beta.34 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 vite: rolldown-vite@7.1.5(@types/node@24.0.13)(yaml@2.8.0) @@ -3905,7 +3905,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.0.13)(@vitest/ui@3.2.4)(jsdom@26.1.0)(lightningcss@1.30.1)(msw@2.10.3(@types/node@24.0.13)(typescript@5.8.3))(yaml@2.8.0) + vitest: 3.2.4(@types/node@24.0.13)(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.1)(msw@2.10.3(@types/node@24.0.13)(typescript@5.8.3))(yaml@2.8.0) transitivePeerDependencies: - supports-color