From a4f56ae91056a9595b1b50a711e1437a89cb58b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Sat, 29 Nov 2025 16:14:04 +0900 Subject: [PATCH 01/20] =?UTF-8?q?chore:=20playwright=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- package.json | 2 + packages/react/package.json | 6 + packages/react/playwright.config.ts | 25 ++ packages/react/vitest.config.ts | 1 + pnpm-lock.yaml | 353 +++++++++++++++++++++++++++- 6 files changed, 382 insertions(+), 9 deletions(-) create mode 100644 packages/react/playwright.config.ts diff --git a/.gitignore b/.gitignore index 7b590ae..5855485 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ dist/ .DS_Store *.tsbuildinfo .turbo -.pnpm-store \ No newline at end of file +.pnpm-store +./packages/react/playwright-report +./packages/react/test-results \ No newline at end of file diff --git a/package.json b/package.json index 5dafd93..acb52ea 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "dev": "turbo run dev", "test": "turbo run test", "test:watch": "turbo run test:watch", + "test:ssr": "pnpm --filter @scrolloop/react test:ssr", + "test:e2e": "pnpm --filter @scrolloop/react test:e2e", "typecheck": "turbo run typecheck", "lint": "turbo run lint", "build:legacy": "tsup", diff --git a/packages/react/package.json b/packages/react/package.json index 86c49e1..d65ac60 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -22,6 +22,8 @@ "dev": "tsup --watch", "test": "vitest run", "test:watch": "vitest", + "test:ssr": "vitest run src/__tests__/ssr/ssr.test.ts", + "test:e2e": "playwright test", "typecheck": "tsc --noEmit" }, "peerDependencies": { @@ -33,15 +35,19 @@ "@scrolloop/shared": "workspace:*" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.0.0", + "@types/express": "^4.17.21", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitest/coverage-v8": "^2.0.0", + "express": "^4.18.2", "jsdom": "^24.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "tsx": "^4.7.0", "tsup": "^8.0.0", "typescript": "^5.0.0", "vitest": "^2.0.0" diff --git a/packages/react/playwright.config.ts b/packages/react/playwright.config.ts new file mode 100644 index 0000000..86259b6 --- /dev/null +++ b/packages/react/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./src/__tests__/ssr", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: "html", + use: { + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "pnpm exec tsx src/__tests__/ssr/server.ts", + port: 3001, + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index 0d307c9..d7f776e 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ environment: "jsdom", setupFiles: ["./vitest.setup.ts"], include: ["src/**/*.{test,spec}.{ts,tsx}"], + exclude: ["src/__tests__/ssr/ssr.e2e.test.ts"], testTimeout: 10000, hookTimeout: 10000, pool: "forks", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91476ff..2f77a7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,7 +55,7 @@ importers: version: 5.44.0 tsup: specifier: ^8.0.0 - version: 8.5.0(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) turbo: specifier: ^2.0.0 version: 2.6.0 @@ -73,7 +73,7 @@ importers: version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@24.1.3)(terser@5.44.0)) tsup: specifier: ^8.0.0 - version: 8.5.0(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) typescript: specifier: ^5.0.0 version: 5.9.3 @@ -90,6 +90,9 @@ importers: specifier: workspace:* version: link:../shared devDependencies: + '@playwright/test': + specifier: ^1.57.0 + version: 1.57.0 '@testing-library/jest-dom': specifier: ^6.0.0 version: 6.9.1 @@ -99,6 +102,9 @@ importers: '@testing-library/user-event': specifier: ^14.0.0 version: 14.6.1(@testing-library/dom@9.3.4) + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 '@types/react': specifier: ^18.2.0 version: 18.3.26 @@ -108,6 +114,9 @@ importers: '@vitest/coverage-v8': specifier: ^2.0.0 version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@24.1.3)(terser@5.44.0)) + express: + specifier: ^4.18.2 + version: 4.21.2 jsdom: specifier: ^24.0.0 version: 24.1.3 @@ -119,7 +128,10 @@ importers: version: 18.3.1(react@18.3.1) tsup: specifier: ^8.0.0 - version: 8.5.0(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) + tsx: + specifier: ^4.7.0 + version: 4.20.6 typescript: specifier: ^5.0.0 version: 5.9.3 @@ -150,7 +162,7 @@ importers: version: 0.72.17(@babel/core@7.28.5)(@babel/preset-env@7.28.5(@babel/core@7.28.5))(react@18.3.1) tsup: specifier: ^8.0.0 - version: 8.5.0(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) typescript: specifier: ^5.0.0 version: 5.9.3 @@ -165,7 +177,7 @@ importers: version: 18.3.1 tsup: specifier: ^8.0.0 - version: 8.5.0(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1) typescript: specifier: ^5.0.0 version: 5.9.3 @@ -1253,6 +1265,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.57.0': + resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} + engines: {node: '>=18'} + hasBin: true + '@react-native-community/cli-clean@11.4.1': resolution: {integrity: sha512-cwUbY3c70oBGv3FvQJWe2Qkq6m1+/dcEBonMDTYyH6i+6OrkzI4RkIGpWmbG1IS5JfE9ISUZkNL3946sxyWNkw==} @@ -1465,9 +1482,24 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/express-serve-static-core@4.19.7': + resolution: {integrity: sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==} + + '@types/express@4.17.25': + resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -1477,12 +1509,21 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/node@24.10.0': resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==} '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -1494,6 +1535,15 @@ packages: '@types/react@18.3.26': resolution: {integrity: sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==} + '@types/send@0.17.6': + resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@1.15.10': + resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -1637,6 +1687,9 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -1715,6 +1768,10 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1936,9 +1993,24 @@ packages: console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + core-js-compat@3.46.0: resolution: {integrity: sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==} @@ -2198,6 +2270,10 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} + express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + engines: {node: '>= 0.10.0'} + fast-xml-parser@4.5.3: resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} hasBin: true @@ -2222,6 +2298,10 @@ packages: resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} engines: {node: '>= 0.8'} + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + find-cache-dir@2.1.0: resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} engines: {node: '>=6'} @@ -2269,6 +2349,10 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -2283,6 +2367,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2322,6 +2411,9 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + github-build@1.2.4: resolution: {integrity: sha512-1kdMmIrvYH18ITHGMVa5BXOxj/+i/VZzPR4PGMBpLW9h15woU+gpM/mlqOk+jmuD4mmib8Dgb6Xcbyy0v+RqqA==} @@ -2415,6 +2507,10 @@ packages: engines: {node: '>=18'} hasBin: true + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -2460,6 +2556,10 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-arguments@1.2.0: resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} engines: {node: '>= 0.4'} @@ -2796,12 +2896,23 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + metro-babel-transformer@0.76.9: resolution: {integrity: sha512-dAnAmBqRdTwTPVn4W4JrowPolxD1MDbuU97u3MqtWZgVRvDpmr+Cqnn5oSxLQk3Uc+Zy3wkqVrB/zXNRlLDSAQ==} engines: {node: '>=16'} @@ -3153,6 +3264,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -3198,6 +3312,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.57.0: + resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.57.0: + resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3262,6 +3386,10 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -3275,6 +3403,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -3285,6 +3417,10 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -3391,6 +3527,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -3807,6 +3946,11 @@ packages: typescript: optional: true + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -3852,6 +3996,10 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -5300,6 +5448,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.57.0': + dependencies: + playwright: 1.57.0 + '@react-native-community/cli-clean@11.4.1': dependencies: '@react-native-community/cli-tools': 11.4.1 @@ -5606,8 +5758,33 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.10.0 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.10.0 + '@types/estree@1.0.8': {} + '@types/express-serve-static-core@4.19.7': + dependencies: + '@types/node': 24.10.0 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@4.17.25': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.7 + '@types/qs': 6.14.0 + '@types/serve-static': 1.15.10 + + '@types/http-errors@2.0.5': {} + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -5618,12 +5795,18 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 + '@types/mime@1.3.5': {} + '@types/node@24.10.0': dependencies: undici-types: 7.16.0 '@types/prop-types@15.7.15': {} + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + '@types/react-dom@18.3.7(@types/react@18.3.26)': dependencies: '@types/react': 18.3.26 @@ -5640,6 +5823,21 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.1.3 + '@types/send@0.17.6': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 24.10.0 + + '@types/send@1.2.1': + dependencies: + '@types/node': 24.10.0 + + '@types/serve-static@1.15.10': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.10.0 + '@types/send': 0.17.6 + '@types/stack-utils@2.0.3': {} '@types/yargs-parser@21.0.3': {} @@ -5790,6 +5988,8 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 + array-flatten@1.1.1: {} + asap@2.0.6: {} assertion-error@2.0.1: {} @@ -5907,6 +6107,23 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -6138,8 +6355,18 @@ snapshots: console-control-strings@1.1.0: {} + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + convert-source-map@2.0.0: {} + cookie-signature@1.0.6: {} + + cookie@0.7.1: {} + core-js-compat@3.46.0: dependencies: browserslist: 4.27.0 @@ -6425,6 +6652,42 @@ snapshots: expect-type@1.2.2: {} + express@4.21.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fast-xml-parser@4.5.3: dependencies: strnum: 1.1.2 @@ -6453,6 +6716,18 @@ snapshots: transitivePeerDependencies: - supports-color + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + find-cache-dir@2.1.0: dependencies: commondir: 1.0.1 @@ -6502,6 +6777,8 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded@0.2.0: {} + fresh@0.5.2: {} fs-constants@1.0.0: {} @@ -6514,6 +6791,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -6558,6 +6838,10 @@ snapshots: get-stream@6.0.1: {} + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + github-build@1.2.4: dependencies: axios: 1.6.0 @@ -6657,6 +6941,10 @@ snapshots: husky@9.1.7: {} + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -6703,6 +6991,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + ipaddr.js@1.9.1: {} + is-arguments@1.2.0: dependencies: call-bound: 1.0.4 @@ -7095,10 +7385,16 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@0.3.0: {} + memoize-one@5.2.1: {} + merge-descriptors@1.0.3: {} + merge-stream@2.0.0: {} + methods@1.1.2: {} + metro-babel-transformer@0.76.9: dependencies: '@babel/core': 7.28.5 @@ -7570,6 +7866,8 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-to-regexp@0.1.12: {} + pathe@1.1.2: {} pathe@2.0.3: {} @@ -7600,13 +7898,22 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 + playwright-core@1.57.0: {} + + playwright@1.57.0: + dependencies: + playwright-core: 1.57.0 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} - postcss-load-config@6.0.1(postcss@8.5.6)(yaml@2.8.1): + postcss-load-config@6.0.1(postcss@8.5.6)(tsx@4.20.6)(yaml@2.8.1): dependencies: lilconfig: 3.1.3 optionalDependencies: postcss: 8.5.6 + tsx: 4.20.6 yaml: 2.8.1 postcss@8.5.6: @@ -7675,6 +7982,11 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} psl@1.15.0: @@ -7688,6 +8000,10 @@ snapshots: punycode@2.3.1: {} + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + querystringify@2.2.0: {} queue@6.0.2: @@ -7696,6 +8012,13 @@ snapshots: range-parser@1.2.1: {} + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -7857,6 +8180,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -8291,7 +8616,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.0(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.1): + tsup@8.5.0(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.1): dependencies: bundle-require: 5.1.0(esbuild@0.25.12) cac: 6.7.14 @@ -8302,7 +8627,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.6)(yaml@2.8.1) + postcss-load-config: 6.0.1(postcss@8.5.6)(tsx@4.20.6)(yaml@2.8.1) resolve-from: 5.0.0 rollup: 4.52.5 source-map: 0.8.0-beta.0 @@ -8319,6 +8644,13 @@ snapshots: - tsx - yaml + tsx@4.20.6: + dependencies: + esbuild: 0.25.12 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -8354,6 +8686,11 @@ snapshots: type-fest@0.7.1: {} + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + typescript@5.9.3: {} ufo@1.6.1: {} From f07d78306e01bbb5ac55f57a2fb425db48230c5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Mon, 22 Dec 2025 11:27:50 +0900 Subject: [PATCH 02/20] =?UTF-8?q?feat:=20isSSR=20=EC=A1=B0=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/react/src/__tests__/ssr/server.ts | 87 +++++++++++ .../react/src/__tests__/ssr/ssr.e2e.test.ts | 61 ++++++++ packages/react/src/__tests__/ssr/ssr.test.ts | 141 ++++++++++++++++++ packages/react/src/utils/isSSR.ts | 3 + 4 files changed, 292 insertions(+) create mode 100644 packages/react/src/__tests__/ssr/server.ts create mode 100644 packages/react/src/__tests__/ssr/ssr.e2e.test.ts create mode 100644 packages/react/src/__tests__/ssr/ssr.test.ts create mode 100644 packages/react/src/utils/isSSR.ts diff --git a/packages/react/src/__tests__/ssr/server.ts b/packages/react/src/__tests__/ssr/server.ts new file mode 100644 index 0000000..9441978 --- /dev/null +++ b/packages/react/src/__tests__/ssr/server.ts @@ -0,0 +1,87 @@ +import express, { type Express, type Request, type Response } from "express"; +import { renderToString } from "react-dom/server"; +import React from "react"; +import { InfiniteList } from "../../components/InfiniteList"; +import type { PageResponse } from "../../types"; +import type { CSSProperties } from "react"; + +const app: Express = express(); +const PORT = Number(process.env.PORT) || 3001; + +interface TestItem { + id: number; + name: string; +} + +app.get("/", async (_req: Request, res: Response) => { + const initialData: TestItem[] = Array(50) + .fill(0) + .map((_, i) => ({ id: i, name: `Item ${i}` })); + + const fetchPage = async ( + page: number, + size: number + ): Promise> => { + const start = page * size; + const end = start + size; + return { + items: initialData.slice(start, end), + total: initialData.length, + hasMore: end < initialData.length, + }; + }; + + const html = renderToString( + React.createElement(InfiniteList, { + fetchPage, + renderItem: ( + item: TestItem | undefined, + index: number, + style: CSSProperties + ) => + React.createElement( + "div", + { + "data-testid": `item-${index}`, + "data-ssr-item": true, + style, + }, + item ? item.name : "Loading..." + ), + itemSize: 50, + height: 400, + pageSize: 20, + isSSR: true, + initialData, + initialTotal: initialData.length, + }) + ); + + res.send(` + + + + SSR Test + + + +
${html}
+ + + `); +}); + +export function startServer(): Promise<{ server: any; port: number }> { + return new Promise((resolve) => { + const server = app.listen(PORT, () => { + console.log(`test server running on http://localhost:${PORT}`); + resolve({ server, port: PORT }); + }); + }); +} + +if (process.argv[1]?.endsWith("server.ts")) { + startServer().catch(console.error); +} diff --git a/packages/react/src/__tests__/ssr/ssr.e2e.test.ts b/packages/react/src/__tests__/ssr/ssr.e2e.test.ts new file mode 100644 index 0000000..478d36b --- /dev/null +++ b/packages/react/src/__tests__/ssr/ssr.e2e.test.ts @@ -0,0 +1,61 @@ +import { test, expect } from "@playwright/test"; +import { startServer } from "./server"; + +let server: any; +let port: number; + +test.beforeAll(async () => { + const result = await startServer(); + server = result.server; + port = result.port; +}); + +test.afterAll(async () => { + if (server) { + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } +}); + +test("should render SSR HTML with initial data", async ({ page }) => { + await page.goto(`http://localhost:${port}`); + + const html = await page.content(); + + expect(html).toContain("data-ssr-list"); + expect(html).toContain("data-ssr-item"); + expect(html).toContain("Item 0"); + expect(html).toContain("Item 49"); +}); + +test("should have SSR attributes on list container", async ({ page }) => { + await page.goto(`http://localhost:${port}`); + + const listContainer = page.locator('[data-ssr-list="true"]'); + await expect(listContainer).toBeVisible(); +}); + +test("should render all initial items in SSR mode", async ({ page }) => { + await page.goto(`http://localhost:${port}`); + + const items = page.locator('[data-ssr-item="true"]'); + const count = await items.count(); + + expect(count).toBeGreaterThan(0); + + const firstItem = items.first(); + await expect(firstItem).toContainText("Item 0"); +}); + +test("should have correct structure for SEO", async ({ page }) => { + await page.goto(`http://localhost:${port}`); + + const html = await page.content(); + + expect(html).toContain("Item 0"); + expect(html).toContain("Item 49"); + + const listContainer = page.locator('[data-ssr-list="true"]'); + await expect(listContainer).toHaveAttribute("role", "list"); +}); diff --git a/packages/react/src/__tests__/ssr/ssr.test.ts b/packages/react/src/__tests__/ssr/ssr.test.ts new file mode 100644 index 0000000..f7b2681 --- /dev/null +++ b/packages/react/src/__tests__/ssr/ssr.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; +import { renderToString } from "react-dom/server"; +import React from "react"; +import { InfiniteList } from "../../components/InfiniteList"; +import type { PageResponse } from "../../types"; +import type { CSSProperties } from "react"; + +interface TestItem { + id: number; + name: string; +} + +describe("SSR - renderToString", () => { + it("should render InfiniteList with initialData on server", () => { + const initialData: TestItem[] = Array(50) + .fill(0) + .map((_, i) => ({ id: i, name: `Item ${i}` })); + + const fetchPage = async ( + page: number, + size: number + ): Promise> => { + const start = page * size; + const end = start + size; + return { + items: initialData.slice(start, end), + total: initialData.length, + hasMore: end < initialData.length, + }; + }; + + const html = renderToString( + React.createElement(InfiniteList, { + fetchPage, + renderItem: ( + item: TestItem | undefined, + index: number, + style: CSSProperties + ) => + React.createElement( + "div", + { + "data-testid": `item-${index}`, + "data-ssr-item": true, + style, + }, + item ? item.name : "Loading..." + ), + itemSize: 50, + height: 400, + pageSize: 20, + isSSR: true, + initialData, + initialTotal: initialData.length, + }) + ); + + expect(html).toBeTruthy(); + expect(html).toContain("data-ssr-list"); + expect(html).toContain("data-ssr-item"); + expect(html).toContain("Item 0"); + expect(html).toContain("Item 49"); + }); + + it("should render all items in SSR mode", () => { + const initialData: TestItem[] = Array(10) + .fill(0) + .map((_, i) => ({ id: i, name: `Item ${i}` })); + + const fetchPage = async (): Promise> => { + return { + items: [], + total: 0, + hasMore: false, + }; + }; + + const html = renderToString( + React.createElement(InfiniteList, { + fetchPage, + renderItem: ( + item: TestItem | undefined, + index: number, + style: CSSProperties + ) => + React.createElement( + "div", + { + "data-testid": `item-${index}`, + style, + }, + item ? item.name : "Loading..." + ), + itemSize: 50, + height: 400, + pageSize: 20, + isSSR: true, + initialData, + initialTotal: initialData.length, + }) + ); + + for (let i = 0; i < initialData.length; i++) { + expect(html).toContain(`Item ${i}`); + } + }); + + it("should include SSR attributes in rendered HTML", () => { + const initialData: TestItem[] = Array(5) + .fill(0) + .map((_, i) => ({ id: i, name: `Item ${i}` })); + + const fetchPage = async (): Promise> => { + return { items: [], total: 0, hasMore: false }; + }; + + const html = renderToString( + React.createElement(InfiniteList, { + fetchPage, + renderItem: ( + item: TestItem | undefined, + _index: number, + style: CSSProperties + ) => + React.createElement( + "div", + { style }, + item ? item.name : "Loading..." + ), + itemSize: 50, + height: 400, + isSSR: true, + initialData, + initialTotal: initialData.length, + }) + ); + + expect(html).toContain('data-ssr-list="true"'); + expect(html).toContain('data-ssr-item="true"'); + }); +}); diff --git a/packages/react/src/utils/isSSR.ts b/packages/react/src/utils/isSSR.ts new file mode 100644 index 0000000..6b55e5a --- /dev/null +++ b/packages/react/src/utils/isSSR.ts @@ -0,0 +1,3 @@ +export function isSSR(): boolean { + return typeof window === "undefined"; +} From 371336f5de66ac1e7f826895806b6ab3c2bb1d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Mon, 22 Dec 2025 11:28:15 +0900 Subject: [PATCH 03/20] =?UTF-8?q?fix:=20ssr=20=EB=B6=84=EA=B8=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/react/src/components/InfiniteList.tsx | 10 ++++++++-- packages/react/src/hooks/useTransition.ts | 5 +++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/InfiniteList.tsx b/packages/react/src/components/InfiniteList.tsx index 368cbe7..249ca50 100644 --- a/packages/react/src/components/InfiniteList.tsx +++ b/packages/react/src/components/InfiniteList.tsx @@ -6,6 +6,7 @@ import { useInfinitePages, findMissingPages } from "@scrolloop/shared"; import { useTransition } from "../hooks/useTransition"; import { calculateVirtualRange } from "@scrolloop/core"; import type { CSSProperties } from "react"; +import { isSSR as isSSREnvironment } from "../utils/isSSR"; function InfiniteListInner(props: InfiniteListProps) { const { @@ -151,8 +152,9 @@ function InfiniteListInner(props: InfiniteListProps) { return { start: renderStart, end: renderEnd }; }, [height, itemSize, mergedAllItems.length, overscan]); + const shouldUseTransition = isSSR; const { isVirtualized } = useTransition({ - enabled: isSSR, + enabled: shouldUseTransition, containerRef, itemSize, totalItems: mergedAllItems.length, @@ -196,6 +198,8 @@ function InfiniteListInner(props: InfiniteListProps) { ); useEffect(() => { + if (isSSREnvironment()) return; + if (!isSSR || !containerRef.current) return; const container = containerRef.current; @@ -316,7 +320,9 @@ function InfiniteListInner(props: InfiniteListProps) { ); } - if (isSSR && !isVirtualized) { + const shouldRenderFullList = isSSREnvironment() || (isSSR && !isVirtualized); + + if (shouldRenderFullList) { return ( { + if (isSSR()) return; + if (!enabled || isHydratedRef.current) return; const checkHydration = () => { @@ -54,6 +57,8 @@ export function useTransition({ }, [enabled, containerRef]); useEffect(() => { + if (isSSR()) return; + if (!enabled || state.type !== "HYDRATED") return; const container = containerRef.current; From 5e14131242ae37b90ced50de5d8000f43a1dea01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Mon, 22 Dec 2025 11:46:52 +0900 Subject: [PATCH 04/20] =?UTF-8?q?fix:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/react/src/components/InfiniteList.tsx | 2 -- packages/react/src/hooks/useTransition.ts | 5 ----- 2 files changed, 7 deletions(-) diff --git a/packages/react/src/components/InfiniteList.tsx b/packages/react/src/components/InfiniteList.tsx index 249ca50..1b04267 100644 --- a/packages/react/src/components/InfiniteList.tsx +++ b/packages/react/src/components/InfiniteList.tsx @@ -198,8 +198,6 @@ function InfiniteListInner(props: InfiniteListProps) { ); useEffect(() => { - if (isSSREnvironment()) return; - if (!isSSR || !containerRef.current) return; const container = containerRef.current; diff --git a/packages/react/src/hooks/useTransition.ts b/packages/react/src/hooks/useTransition.ts index b6f5225..6a4f334 100644 --- a/packages/react/src/hooks/useTransition.ts +++ b/packages/react/src/hooks/useTransition.ts @@ -6,7 +6,6 @@ import { } from "../utils/domPruner"; import type { TransitionState, TransitionStrategy } from "../types"; import { captureSnapshot, restoreSnapshot } from "../utils/transitionSnapshot"; -import { isSSR } from "../utils/isSSR"; interface useTransitionOptions { enabled: boolean; @@ -38,8 +37,6 @@ export function useTransition({ const transitionStrategy = { ...defaultTransitionStrategy, ...strategy }; useEffect(() => { - if (isSSR()) return; - if (!enabled || isHydratedRef.current) return; const checkHydration = () => { @@ -57,8 +54,6 @@ export function useTransition({ }, [enabled, containerRef]); useEffect(() => { - if (isSSR()) return; - if (!enabled || state.type !== "HYDRATED") return; const container = containerRef.current; From 0f132f217bd54df0aa8d83f136426fc809ca4f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Sat, 20 Dec 2025 11:48:03 +0900 Subject: [PATCH 05/20] rename: ssr to serverside --- packages/react/src/__tests__/ssr/server.ts | 2 +- packages/react/src/__tests__/ssr/ssr.test.ts | 6 +-- .../react/src/components/InfiniteList.tsx | 39 ++++++++++--------- packages/react/src/types.ts | 2 +- .../src/utils/{isSSR.ts => isServerSide.ts} | 2 +- 5 files changed, 26 insertions(+), 25 deletions(-) rename packages/react/src/utils/{isSSR.ts => isServerSide.ts} (50%) diff --git a/packages/react/src/__tests__/ssr/server.ts b/packages/react/src/__tests__/ssr/server.ts index 9441978..8eda371 100644 --- a/packages/react/src/__tests__/ssr/server.ts +++ b/packages/react/src/__tests__/ssr/server.ts @@ -51,7 +51,7 @@ app.get("/", async (_req: Request, res: Response) => { itemSize: 50, height: 400, pageSize: 20, - isSSR: true, + isServerSide: true, initialData, initialTotal: initialData.length, }) diff --git a/packages/react/src/__tests__/ssr/ssr.test.ts b/packages/react/src/__tests__/ssr/ssr.test.ts index f7b2681..6c5f2a3 100644 --- a/packages/react/src/__tests__/ssr/ssr.test.ts +++ b/packages/react/src/__tests__/ssr/ssr.test.ts @@ -49,7 +49,7 @@ describe("SSR - renderToString", () => { itemSize: 50, height: 400, pageSize: 20, - isSSR: true, + isServerSide: true, initialData, initialTotal: initialData.length, }) @@ -94,7 +94,7 @@ describe("SSR - renderToString", () => { itemSize: 50, height: 400, pageSize: 20, - isSSR: true, + isServerSide: true, initialData, initialTotal: initialData.length, }) @@ -129,7 +129,7 @@ describe("SSR - renderToString", () => { ), itemSize: 50, height: 400, - isSSR: true, + isServerSide: true, initialData, initialTotal: initialData.length, }) diff --git a/packages/react/src/components/InfiniteList.tsx b/packages/react/src/components/InfiniteList.tsx index 1b04267..59a1852 100644 --- a/packages/react/src/components/InfiniteList.tsx +++ b/packages/react/src/components/InfiniteList.tsx @@ -6,7 +6,7 @@ import { useInfinitePages, findMissingPages } from "@scrolloop/shared"; import { useTransition } from "../hooks/useTransition"; import { calculateVirtualRange } from "@scrolloop/core"; import type { CSSProperties } from "react"; -import { isSSR as isSSREnvironment } from "../utils/isSSR"; +import { isServerSide as isServerSideEnvironment } from "../utils/isServerSide"; function InfiniteListInner(props: InfiniteListProps) { const { @@ -25,7 +25,7 @@ function InfiniteListInner(props: InfiniteListProps) { renderEmpty, onPageLoad, onError, - isSSR = false, + isServerSide = false, transitionStrategy, initialData, initialTotal, @@ -42,7 +42,7 @@ function InfiniteListInner(props: InfiniteListProps) { const initialTotalRef = useRef(0); const initialHasMoreRef = useRef(true); - if (isSSR && initialData && initialData.length > 0) { + if (isServerSide && initialData && initialData.length > 0) { const initialPages = new Map(); const totalPages = Math.ceil(initialData.length / pageSize); @@ -69,7 +69,7 @@ function InfiniteListInner(props: InfiniteListProps) { }); const mergedPages = useMemo(() => { - if (isSSR && initialPagesRef.current.size > 0) { + if (isServerSide && initialPagesRef.current.size > 0) { const merged = new Map(pages); initialPagesRef.current.forEach((items, pageNum) => { if (!merged.has(pageNum)) { @@ -79,24 +79,24 @@ function InfiniteListInner(props: InfiniteListProps) { return merged; } return pages; - }, [pages, isSSR]); + }, [pages, isServerSide]); const mergedTotal = useMemo(() => { - if (isSSR && initialTotalRef.current > 0) { + if (isServerSide && initialTotalRef.current > 0) { return Math.max(initialTotalRef.current, allItems.length); } return allItems.length; - }, [isSSR, allItems.length]); + }, [isServerSide, allItems.length]); const mergedHasMore = useMemo(() => { - if (isSSR && initialPagesRef.current.size > 0) { + if (isServerSide && initialPagesRef.current.size > 0) { return initialHasMoreRef.current || hasMore; } return hasMore; - }, [isSSR, hasMore]); + }, [isServerSide, hasMore]); const mergedAllItems = useMemo(() => { - if (isSSR && initialData && initialData.length > 0) { + if (isServerSide && initialData && initialData.length > 0) { const items: (T | undefined)[] = new Array(mergedTotal); initialData.forEach((item, index) => { @@ -113,10 +113,10 @@ function InfiniteListInner(props: InfiniteListProps) { return items; } return allItems; - }, [isSSR, initialData, mergedTotal, mergedPages, pageSize, allItems]); + }, [isServerSide, initialData, mergedTotal, mergedPages, pageSize, allItems]); useEffect(() => { - if (!isSSR && mergedPages.size === 0 && !error) { + if (!isServerSide && mergedPages.size === 0 && !error) { const totalNeededItems = Math.ceil(height / itemSize) + overscan * 2; for ( let page = 0; @@ -126,7 +126,7 @@ function InfiniteListInner(props: InfiniteListProps) { loadPage(page); } }, [ - isSSR, + isServerSide, mergedPages.size, loadPage, initialPage, @@ -152,7 +152,7 @@ function InfiniteListInner(props: InfiniteListProps) { return { start: renderStart, end: renderEnd }; }, [height, itemSize, mergedAllItems.length, overscan]); - const shouldUseTransition = isSSR; + const shouldUseTransition = isServerSide; const { isVirtualized } = useTransition({ enabled: shouldUseTransition, containerRef, @@ -164,7 +164,7 @@ function InfiniteListInner(props: InfiniteListProps) { const handleRangeChange = useCallback( (range: { startIndex: number; endIndex: number }) => { - if (isSSR && !isVirtualized) { + if (isServerSide && !isVirtualized) { scrollTopRef.current = containerRef.current?.scrollTop ?? 0; return; } @@ -186,7 +186,7 @@ function InfiniteListInner(props: InfiniteListProps) { } }, [ - isSSR, + isServerSide, isVirtualized, pageSize, prefetchThreshold, @@ -198,7 +198,7 @@ function InfiniteListInner(props: InfiniteListProps) { ); useEffect(() => { - if (!isSSR || !containerRef.current) return; + if (!isServerSide || !containerRef.current) return; const container = containerRef.current; const handleScroll = () => { @@ -207,7 +207,7 @@ function InfiniteListInner(props: InfiniteListProps) { container.addEventListener("scroll", handleScroll, { passive: true }); return () => container.removeEventListener("scroll", handleScroll); - }, [isSSR]); + }, [isServerSide]); const virtualListRenderItem = useCallback( (index: number, itemStyle: CSSProperties) => { @@ -318,7 +318,8 @@ function InfiniteListInner(props: InfiniteListProps) { ); } - const shouldRenderFullList = isSSREnvironment() || (isSSR && !isVirtualized); + const shouldRenderFullList = + isServerSideEnvironment() || (isServerSide && !isVirtualized); if (shouldRenderFullList) { return ( diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 54b4355..014ac34 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -65,7 +65,7 @@ export interface InfiniteListProps { onPageLoad?: (page: number, items: T[]) => void; onError?: (error: Error) => void; - isSSR?: boolean; + isServerSide?: boolean; transitionStrategy?: TransitionStrategy; initialData?: T[]; diff --git a/packages/react/src/utils/isSSR.ts b/packages/react/src/utils/isServerSide.ts similarity index 50% rename from packages/react/src/utils/isSSR.ts rename to packages/react/src/utils/isServerSide.ts index 6b55e5a..1fecc6c 100644 --- a/packages/react/src/utils/isSSR.ts +++ b/packages/react/src/utils/isServerSide.ts @@ -1,3 +1,3 @@ -export function isSSR(): boolean { +export function isServerSide(): boolean { return typeof window === "undefined"; } From a62655993d2a54be41fe56b95b7073e5c489de9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Sun, 21 Dec 2025 15:15:04 +0900 Subject: [PATCH 06/20] feat: add hydration test --- packages/react/src/__tests__/ssr/client.tsx | 38 +++++++++++++++++++ packages/react/src/__tests__/ssr/server.ts | 32 ++++++++++++++-- .../react/src/__tests__/ssr/ssr.e2e.test.ts | 16 ++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 packages/react/src/__tests__/ssr/client.tsx diff --git a/packages/react/src/__tests__/ssr/client.tsx b/packages/react/src/__tests__/ssr/client.tsx new file mode 100644 index 0000000..759dd0b --- /dev/null +++ b/packages/react/src/__tests__/ssr/client.tsx @@ -0,0 +1,38 @@ +import { hydrateRoot } from "react-dom/client"; +import { InfiniteList } from "../../components/InfiniteList"; +import type { PageResponse } from "../../types"; + +const initialData = Array(50) + .fill(0) + .map((_, i) => ({ id: i, name: `Item ${i}` })); + +const fetchPage = async ( + page: number, + size: number +): Promise> => { + const start = page * size; + const end = start + size; + return { + items: initialData.slice(start, end), + total: initialData.length, + hasMore: end < initialData.length, + }; +}; + +hydrateRoot( + document.getElementById("root")!, + ( +
+ {item ? item.name : "Loading..."} +
+ )} + itemSize={50} + height={400} + pageSize={20} + isServerSide={true} + initialData={initialData} + initialTotal={initialData.length} + /> +); diff --git a/packages/react/src/__tests__/ssr/server.ts b/packages/react/src/__tests__/ssr/server.ts index 8eda371..c362d20 100644 --- a/packages/react/src/__tests__/ssr/server.ts +++ b/packages/react/src/__tests__/ssr/server.ts @@ -4,6 +4,8 @@ import React from "react"; import { InfiniteList } from "../../components/InfiniteList"; import type { PageResponse } from "../../types"; import type { CSSProperties } from "react"; +import * as esbuild from "esbuild"; +import path from "path"; const app: Express = express(); const PORT = Number(process.env.PORT) || 3001; @@ -13,6 +15,29 @@ interface TestItem { name: string; } +let clientBundle = ""; +try { + const result = esbuild.buildSync({ + entryPoints: [path.resolve(__dirname, "./client.tsx")], + bundle: true, + write: false, + format: "esm", + target: "es2020", + platform: "browser", + define: { + "process.env.NODE_ENV": '"development"', + }, + }); + clientBundle = result.outputFiles[0].text; +} catch (e) { + console.error("Failed to bundle client code:", e); +} + +app.get("/bundle.js", (_req, res) => { + res.header("Content-Type", "application/javascript"); + res.send(clientBundle); +}); + app.get("/", async (_req: Request, res: Response) => { const initialData: TestItem[] = Array(50) .fill(0) @@ -62,12 +87,13 @@ app.get("/", async (_req: Request, res: Response) => { SSR Test - +
${html}
+ `); diff --git a/packages/react/src/__tests__/ssr/ssr.e2e.test.ts b/packages/react/src/__tests__/ssr/ssr.e2e.test.ts index 478d36b..16099ea 100644 --- a/packages/react/src/__tests__/ssr/ssr.e2e.test.ts +++ b/packages/react/src/__tests__/ssr/ssr.e2e.test.ts @@ -59,3 +59,19 @@ test("should have correct structure for SEO", async ({ page }) => { const listContainer = page.locator('[data-ssr-list="true"]'); await expect(listContainer).toHaveAttribute("role", "list"); }); + +test("should hydrate and switch to virtual list", async ({ page }) => { + await page.goto(`http://localhost:${port}`); + + // Initial SSR render (FullList) + await expect(page.locator('[data-ssr-list="true"]')).toBeVisible(); + + // Wait for hydration and virtualization switch + // The library switches to VirtualList after hydration, so [data-ssr-list] attribute should be removed + await expect(page.locator('[data-ssr-list="true"]')).toBeHidden({ + timeout: 5000, + }); + + // Check if items are still visible (rendered by VirtualList) + await expect(page.getByText("Item 0")).toBeVisible(); +}); From f5d6416734c1b372ea74c6323c75ac281318a088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Mon, 22 Dec 2025 15:16:55 +0900 Subject: [PATCH 07/20] =?UTF-8?q?chore:=20CI=EC=97=90=20E2E=20test=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 6 +- packages/react/package.json | 3 +- pnpm-lock.yaml | 271 ++++++++++++++++++++++++++++++++++++ 3 files changed, 277 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51d2cb3..dbca478 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,6 @@ concurrency: jobs: ci: runs-on: ubuntu-latest - strategy: - fail-fast: false steps: - uses: actions/checkout@v4 with: @@ -53,9 +51,13 @@ jobs: - name: Typecheck run: pnpm run typecheck + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Test run: | if pnpm run | grep -q "^ *test *"; then pnpm test; else echo "no test script"; fi + pnpm --filter @scrolloop/react test:e2e - name: Build run: pnpm run build diff --git a/packages/react/package.json b/packages/react/package.json index d65ac60..5290ad6 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -43,12 +43,13 @@ "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitest/coverage-v8": "^2.0.0", + "esbuild": "^0.27.2", "express": "^4.18.2", "jsdom": "^24.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "tsx": "^4.7.0", "tsup": "^8.0.0", + "tsx": "^4.7.0", "typescript": "^5.0.0", "vitest": "^2.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f77a7e..2037888 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,9 @@ importers: '@vitest/coverage-v8': specifier: ^2.0.0 version: 2.1.9(vitest@2.1.9(@types/node@24.10.0)(jsdom@24.1.3)(terser@5.44.0)) + esbuild: + specifier: ^0.27.2 + version: 0.27.2 express: specifier: ^4.18.2 version: 4.21.2 @@ -918,6 +921,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} @@ -930,6 +939,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} @@ -942,6 +957,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} @@ -954,6 +975,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} @@ -966,6 +993,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} @@ -978,6 +1011,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} @@ -990,6 +1029,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} @@ -1002,6 +1047,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} @@ -1014,6 +1065,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} @@ -1026,6 +1083,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} @@ -1038,6 +1101,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} @@ -1050,6 +1119,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} @@ -1062,6 +1137,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} @@ -1074,6 +1155,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} @@ -1086,6 +1173,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} @@ -1098,6 +1191,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} @@ -1110,12 +1209,24 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} @@ -1128,12 +1239,24 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} @@ -1146,12 +1269,24 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} @@ -1164,6 +1299,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} @@ -1176,6 +1317,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} @@ -1188,6 +1335,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} @@ -1200,6 +1353,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@hapi/hoek@9.3.0': resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} @@ -2220,6 +2379,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -5214,147 +5378,225 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true + '@esbuild/aix-ppc64@0.27.2': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true '@esbuild/android-arm64@0.25.12': optional: true + '@esbuild/android-arm64@0.27.2': + optional: true + '@esbuild/android-arm@0.21.5': optional: true '@esbuild/android-arm@0.25.12': optional: true + '@esbuild/android-arm@0.27.2': + optional: true + '@esbuild/android-x64@0.21.5': optional: true '@esbuild/android-x64@0.25.12': optional: true + '@esbuild/android-x64@0.27.2': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true '@esbuild/darwin-arm64@0.25.12': optional: true + '@esbuild/darwin-arm64@0.27.2': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true '@esbuild/darwin-x64@0.25.12': optional: true + '@esbuild/darwin-x64@0.27.2': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true '@esbuild/freebsd-arm64@0.25.12': optional: true + '@esbuild/freebsd-arm64@0.27.2': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true '@esbuild/freebsd-x64@0.25.12': optional: true + '@esbuild/freebsd-x64@0.27.2': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true '@esbuild/linux-arm64@0.25.12': optional: true + '@esbuild/linux-arm64@0.27.2': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true '@esbuild/linux-arm@0.25.12': optional: true + '@esbuild/linux-arm@0.27.2': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true '@esbuild/linux-ia32@0.25.12': optional: true + '@esbuild/linux-ia32@0.27.2': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true '@esbuild/linux-loong64@0.25.12': optional: true + '@esbuild/linux-loong64@0.27.2': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true '@esbuild/linux-mips64el@0.25.12': optional: true + '@esbuild/linux-mips64el@0.27.2': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true '@esbuild/linux-ppc64@0.25.12': optional: true + '@esbuild/linux-ppc64@0.27.2': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true '@esbuild/linux-riscv64@0.25.12': optional: true + '@esbuild/linux-riscv64@0.27.2': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true '@esbuild/linux-s390x@0.25.12': optional: true + '@esbuild/linux-s390x@0.27.2': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true '@esbuild/linux-x64@0.25.12': optional: true + '@esbuild/linux-x64@0.27.2': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true + '@esbuild/netbsd-arm64@0.27.2': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true '@esbuild/netbsd-x64@0.25.12': optional: true + '@esbuild/netbsd-x64@0.27.2': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true + '@esbuild/openbsd-arm64@0.27.2': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true '@esbuild/openbsd-x64@0.25.12': optional: true + '@esbuild/openbsd-x64@0.27.2': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/openharmony-arm64@0.27.2': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true '@esbuild/sunos-x64@0.25.12': optional: true + '@esbuild/sunos-x64@0.27.2': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true '@esbuild/win32-arm64@0.25.12': optional: true + '@esbuild/win32-arm64@0.27.2': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true '@esbuild/win32-ia32@0.25.12': optional: true + '@esbuild/win32-ia32@0.27.2': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true '@esbuild/win32-x64@0.25.12': optional: true + '@esbuild/win32-x64@0.27.2': + optional: true + '@hapi/hoek@9.3.0': {} '@hapi/topo@5.1.0': @@ -6614,6 +6856,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + escalade@3.2.0: {} escape-html@1.0.3: {} From a95baf5d48d2cecfd6fde2fb6b5941f0696ace5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Sun, 21 Dec 2025 15:54:58 +0900 Subject: [PATCH 08/20] =?UTF-8?q?fix:=20esmodule=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EC=97=90=EC=84=9C=20cjs=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/react/src/__tests__/ssr/server.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react/src/__tests__/ssr/server.ts b/packages/react/src/__tests__/ssr/server.ts index c362d20..9407c8f 100644 --- a/packages/react/src/__tests__/ssr/server.ts +++ b/packages/react/src/__tests__/ssr/server.ts @@ -6,6 +6,10 @@ import type { PageResponse } from "../../types"; import type { CSSProperties } from "react"; import * as esbuild from "esbuild"; import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); const app: Express = express(); const PORT = Number(process.env.PORT) || 3001; From 23d9e6f08e9064ce858eab30a9b2f4965a34334f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Mon, 22 Dec 2025 16:09:05 +0900 Subject: [PATCH 09/20] =?UTF-8?q?playwright=EA=B0=80=20vitest=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=ED=95=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/react/playwright.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/playwright.config.ts b/packages/react/playwright.config.ts index 86259b6..edf2f6d 100644 --- a/packages/react/playwright.config.ts +++ b/packages/react/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./src/__tests__/ssr", + testMatch: "**/*.e2e.test.ts", fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, From 7e13ea0eefa49c7690c3108437ed555b7a816587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Mon, 22 Dec 2025 17:33:02 +0900 Subject: [PATCH 10/20] =?UTF-8?q?fix:=20Playwright=EA=B0=80=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8C=8C=EC=9D=BC=EC=97=90=20=EB=94=B0=EB=9D=BC=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EA=B4=80=EB=A6=AC=20=EC=A0=84=EB=8B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../react/src/__tests__/ssr/ssr.e2e.test.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/packages/react/src/__tests__/ssr/ssr.e2e.test.ts b/packages/react/src/__tests__/ssr/ssr.e2e.test.ts index 16099ea..d45b159 100644 --- a/packages/react/src/__tests__/ssr/ssr.e2e.test.ts +++ b/packages/react/src/__tests__/ssr/ssr.e2e.test.ts @@ -1,22 +1,6 @@ import { test, expect } from "@playwright/test"; -import { startServer } from "./server"; -let server: any; -let port: number; - -test.beforeAll(async () => { - const result = await startServer(); - server = result.server; - port = result.port; -}); - -test.afterAll(async () => { - if (server) { - await new Promise((resolve) => { - server.close(() => resolve()); - }); - } -}); +const port = 3001; test("should render SSR HTML with initial data", async ({ page }) => { await page.goto(`http://localhost:${port}`); From 90f291091c49378ab0aa47b4f7499aac0c3bed74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Tue, 23 Dec 2025 09:57:02 +0900 Subject: [PATCH 11/20] chore: add coverage/ to gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5855485..0edfff7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ dist/ .turbo .pnpm-store ./packages/react/playwright-report -./packages/react/test-results \ No newline at end of file +./packages/react/test-results +coverage/ \ No newline at end of file From 42e80d53c35dff99346c4675e0da8bbc7ab58aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Tue, 23 Dec 2025 10:04:11 +0900 Subject: [PATCH 12/20] =?UTF-8?q?chore:=20type=20=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=95=20=EB=8C=80=EC=83=81=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/vitest.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 27ab500..5e43252 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -24,6 +24,9 @@ export default defineConfig({ "**/index.ts", "**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}", + "**/Plugin.ts", + "**/LayoutStrategy.ts", + "**/ScrollSource.ts", ], }, }, From 5caabcf1c5f20ba351b3826150c3fb49ce0543f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Tue, 23 Dec 2025 10:04:49 +0900 Subject: [PATCH 13/20] feat: VertualSrcollSource.ts test --- .../strategies/scroll/VirtualScrollSource.ts | 8 +- .../core/src/virtualizer/Virtualizer.test.ts | 147 +++++++++++++----- 2 files changed, 109 insertions(+), 46 deletions(-) diff --git a/packages/core/src/strategies/scroll/VirtualScrollSource.ts b/packages/core/src/strategies/scroll/VirtualScrollSource.ts index b67fb9c..08a9ac6 100644 --- a/packages/core/src/strategies/scroll/VirtualScrollSource.ts +++ b/packages/core/src/strategies/scroll/VirtualScrollSource.ts @@ -1,4 +1,4 @@ -import type { ScrollSource } from './ScrollSource'; +import type { ScrollSource } from "./ScrollSource"; export class VirtualScrollSource implements ScrollSource { private scrollOffset = 0; @@ -21,7 +21,10 @@ export class VirtualScrollSource implements ScrollSource { } setViewportSize(size: number): void { - this.viewportSize = size; + if (this.viewportSize !== size) { + this.viewportSize = size; + this.notifyListeners(); + } } subscribe(callback: (offset: number) => void): () => void { @@ -35,4 +38,3 @@ export class VirtualScrollSource implements ScrollSource { this.listeners.forEach((listener) => listener(this.scrollOffset)); } } - diff --git a/packages/core/src/virtualizer/Virtualizer.test.ts b/packages/core/src/virtualizer/Virtualizer.test.ts index 5e81c17..72d18b7 100644 --- a/packages/core/src/virtualizer/Virtualizer.test.ts +++ b/packages/core/src/virtualizer/Virtualizer.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { Virtualizer } from "./Virtualizer"; import { FixedLayoutStrategy } from "../strategies/layout/FixedLayoutStrategy"; import { VirtualScrollSource } from "../strategies/scroll/VirtualScrollSource"; +import { Plugin } from "../plugins/Plugin"; +import { VirtualizerState, Range } from "../types"; describe("Virtualizer", () => { let layoutStrategy: FixedLayoutStrategy; @@ -19,34 +21,14 @@ describe("Virtualizer", () => { it("should initialize with correct state", () => { const state = virtualizer.getState(); - expect(state.virtualItems).toHaveLength(3); // Initial viewport 0 + 2 overscan = 3 items (0, 1, 2) - expect(state.totalSize).toBe(5000); // 100 items * 50px + expect(state.virtualItems).toHaveLength(3); + expect(state.totalSize).toBe(5000); }); it("should update state when viewport size changes", () => { - // Simulate viewport resize - // Since VirtualScrollSource doesn't expose a direct way to set viewport size for testing without DOM, - // we might need to mock it or rely on its internal behavior if it was real. - // However, VirtualScrollSource is headless, so we can manually trigger updates if we had access. - // For this test, we'll mock the scroll source methods. - - const mockScrollSource = { - getScrollOffset: vi.fn().mockReturnValue(0), - getViewportSize: vi.fn().mockReturnValue(500), - subscribe: vi.fn().mockReturnValue(() => {}), - scrollTo: vi.fn(), - setScrollOffset: vi.fn(), - destroy: vi.fn(), - }; - - virtualizer = new Virtualizer(layoutStrategy, mockScrollSource, { - count: 100, - overscan: 2, - }); + scrollSource.setViewportSize(500); const state = virtualizer.getState(); - // visibleRange.endIndex is 10, so the rendered items range is - // 0-12 including overscan. (Total 13 items) expect(state.virtualItems).toHaveLength(13); expect(state.visibleRange.startIndex).toBe(0); expect(state.visibleRange.endIndex).toBe(10); @@ -55,32 +37,111 @@ describe("Virtualizer", () => { it("should update state when count changes", () => { virtualizer.setCount(200); const state = virtualizer.getState(); - expect(state.totalSize).toBe(10000); // 200 * 50 + expect(state.totalSize).toBe(10000); }); - it("should handle scroll updates", () => { - const mockScrollSource = { - getScrollOffset: vi.fn().mockReturnValue(100), // Scrolled 100px - getViewportSize: vi.fn().mockReturnValue(500), - subscribe: vi.fn((callback) => { - // Simulate scroll event immediately for testing - callback(); - return () => {}; - }), - scrollTo: vi.fn(), - setScrollOffset: vi.fn(), - destroy: vi.fn(), - }; - - virtualizer = new Virtualizer(layoutStrategy, mockScrollSource, { + it("should call onChange when state updates", () => { + const onChange = vi.fn(); + virtualizer = new Virtualizer(layoutStrategy, scrollSource, { count: 100, - overscan: 0, + overscan: 2, + onChange, }); - // Initial state calculation happens in constructor - const state = virtualizer.getState(); + virtualizer.update(); + expect(onChange).toHaveBeenCalledWith(virtualizer.getState()); + }); - // 100px scroll / 50px item = start index 2 + it("should handle scroll updates", () => { + scrollSource.setViewportSize(500); + scrollSource.setScrollOffset(100); + + const state = virtualizer.getState(); expect(state.visibleRange.startIndex).toBe(2); }); + + it("should not update if count is same", () => { + const updateSpy = vi.spyOn(virtualizer, "update"); + virtualizer.setCount(100); + expect(updateSpy).not.toHaveBeenCalled(); + virtualizer.setCount(101); + expect(updateSpy).toHaveBeenCalled(); + }); + + describe("Plugins", () => { + it("should initialize plugins on constructor", () => { + const plugin: Plugin = { + name: "test", + onInit: vi.fn(), + }; + virtualizer.addPlugin(plugin); + expect(plugin.onInit).toHaveBeenCalled(); + }); + + it("should allow plugins to modify state via beforeStateChange", () => { + const plugin: Plugin = { + name: "test", + beforeStateChange: vi.fn((state) => { + return { ...state, totalSize: 9999 }; + }), + }; + virtualizer.addPlugin(plugin); + + virtualizer.update(); + + expect(virtualizer.getState().totalSize).toBe(9999); + expect(plugin.beforeStateChange).toHaveBeenCalled(); + }); + + it("should notify plugins via afterStateChange", () => { + const plugin: Plugin = { + name: "test", + afterStateChange: vi.fn(), + }; + virtualizer.addPlugin(plugin); + virtualizer.update(); + expect(plugin.afterStateChange).toHaveBeenCalledWith( + virtualizer.getState() + ); + }); + + it("should allow plugins to modify range via onRangeCalculated", () => { + const plugin: Plugin = { + name: "test", + onRangeCalculated: vi.fn((range, count) => { + return { startIndex: 0, endIndex: 0 }; + }), + }; + virtualizer.addPlugin(plugin); + virtualizer.update(); + + const state = virtualizer.getState(); + expect(state.renderRange).toEqual({ startIndex: 0, endIndex: 0 }); + expect(state.virtualItems).toHaveLength(1); + }); + + it("should call onDestroy when virtualizer is destroyed", () => { + const plugin: Plugin = { + name: "test", + onDestroy: vi.fn(), + }; + virtualizer.addPlugin(plugin); + virtualizer.destroy(); + expect(plugin.onDestroy).toHaveBeenCalled(); + }); + }); + + it("should unsubscribe from scroll source on destroy", () => { + const originalSubscribe = scrollSource.subscribe.bind(scrollSource); + const unsubscribeSpy = vi.fn(); + + scrollSource.subscribe = vi.fn(() => { + return unsubscribeSpy; + }); + + virtualizer = new Virtualizer(layoutStrategy, scrollSource, { count: 100 }); + + virtualizer.destroy(); + expect(unsubscribeSpy).toHaveBeenCalled(); + }); }); From 6c19ee049ba36b88312a0888350aa2c3a6701828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Tue, 23 Dec 2025 10:05:01 +0900 Subject: [PATCH 14/20] feat: OverScanPlugin test --- .../core/src/plugins/OverscanPlugin.test.ts | 50 +++++++++++++ .../scroll/VirtualScrollSource.test.ts | 74 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 packages/core/src/plugins/OverscanPlugin.test.ts create mode 100644 packages/core/src/strategies/scroll/VirtualScrollSource.test.ts diff --git a/packages/core/src/plugins/OverscanPlugin.test.ts b/packages/core/src/plugins/OverscanPlugin.test.ts new file mode 100644 index 0000000..d560c4b --- /dev/null +++ b/packages/core/src/plugins/OverscanPlugin.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { OverscanPlugin } from "./OverscanPlugin"; +import { Range } from "../types"; + +describe("OverscanPlugin", () => { + it("should initialize with default overscan", () => { + const plugin = new OverscanPlugin(); + const range: Range = { startIndex: 10, endIndex: 20 }; + const count = 100; + const result = plugin.onRangeCalculated(range, count); + + expect(result.startIndex).toBe(6); + expect(result.endIndex).toBe(24); + }); + + it("should initialize with custom overscan", () => { + const plugin = new OverscanPlugin(2); + const range: Range = { startIndex: 10, endIndex: 20 }; + const count = 100; + const result = plugin.onRangeCalculated(range, count); + + expect(result.startIndex).toBe(8); + expect(result.endIndex).toBe(22); + }); + + it("should clamp start index to 0", () => { + const plugin = new OverscanPlugin(5); + const range: Range = { startIndex: 2, endIndex: 10 }; + const count = 100; + const result = plugin.onRangeCalculated(range, count); + + expect(result.startIndex).toBe(0); + expect(result.endIndex).toBe(15); + }); + + it("should clamp end index to count - 1", () => { + const plugin = new OverscanPlugin(5); + const range: Range = { startIndex: 90, endIndex: 98 }; + const count = 100; + const result = plugin.onRangeCalculated(range, count); + + expect(result.startIndex).toBe(85); + expect(result.endIndex).toBe(99); + }); + + it("should have correct name", () => { + const plugin = new OverscanPlugin(); + expect(plugin.name).toBe("overscan"); + }); +}); diff --git a/packages/core/src/strategies/scroll/VirtualScrollSource.test.ts b/packages/core/src/strategies/scroll/VirtualScrollSource.test.ts new file mode 100644 index 0000000..40f149d --- /dev/null +++ b/packages/core/src/strategies/scroll/VirtualScrollSource.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from "vitest"; +import { VirtualScrollSource } from "./VirtualScrollSource"; + +describe("VirtualScrollSource", () => { + it("should initialize with default values", () => { + const source = new VirtualScrollSource(); + expect(source.getScrollOffset()).toBe(0); + expect(source.getViewportSize()).toBe(0); + }); + + it("should update viewport size and notify listeners", () => { + const source = new VirtualScrollSource(); + const listener = vi.fn(); + source.subscribe(listener); + + source.setViewportSize(500); + expect(source.getViewportSize()).toBe(500); + expect(listener).toHaveBeenCalledWith(0); + }); + + it("should update scroll offset and notify listeners", () => { + const source = new VirtualScrollSource(); + const listener = vi.fn(); + source.subscribe(listener); + + source.setScrollOffset(100); + + expect(source.getScrollOffset()).toBe(100); + expect(listener).toHaveBeenCalledWith(100); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("should not notify listeners if scroll offset is the same", () => { + const source = new VirtualScrollSource(); + const listener = vi.fn(); + source.subscribe(listener); + + source.setScrollOffset(0); + expect(listener).not.toHaveBeenCalled(); + + source.setScrollOffset(100); + listener.mockClear(); + + source.setScrollOffset(100); + expect(listener).not.toHaveBeenCalled(); + }); + + it("should unsubscribe correctly", () => { + const source = new VirtualScrollSource(); + const listener = vi.fn(); + const unsubscribe = source.subscribe(listener); + + source.setScrollOffset(100); + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + source.setScrollOffset(200); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("should handle multiple listeners", () => { + const source = new VirtualScrollSource(); + const listenerA = vi.fn(); + const listenerB = vi.fn(); + + source.subscribe(listenerA); + source.subscribe(listenerB); + + source.setScrollOffset(50); + + expect(listenerA).toHaveBeenCalledWith(50); + expect(listenerB).toHaveBeenCalledWith(50); + }); +}); From b82ac54b40e3233354021aeae7e92b43ce6e8e22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Tue, 23 Dec 2025 10:08:13 +0900 Subject: [PATCH 15/20] =?UTF-8?q?refector:=20=EB=94=94=EB=A0=89=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/plugins/{ => __tests__}/OverscanPlugin.test.ts | 4 ++-- .../scroll/{ => __tests__}/VirtualScrollSource.test.ts | 2 +- .../{ => __tests__}/calculateVirtualRange.test.ts | 2 +- packages/core/src/utils/{ => __tests__}/clamp.test.ts | 2 +- .../src/virtualizer/{ => __test__}/Virtualizer.test.ts | 10 +++++----- 5 files changed, 10 insertions(+), 10 deletions(-) rename packages/core/src/plugins/{ => __tests__}/OverscanPlugin.test.ts (94%) rename packages/core/src/strategies/scroll/{ => __tests__}/VirtualScrollSource.test.ts (97%) rename packages/core/src/utils/{ => __tests__}/calculateVirtualRange.test.ts (98%) rename packages/core/src/utils/{ => __tests__}/clamp.test.ts (97%) rename packages/core/src/virtualizer/{ => __test__}/Virtualizer.test.ts (92%) diff --git a/packages/core/src/plugins/OverscanPlugin.test.ts b/packages/core/src/plugins/__tests__/OverscanPlugin.test.ts similarity index 94% rename from packages/core/src/plugins/OverscanPlugin.test.ts rename to packages/core/src/plugins/__tests__/OverscanPlugin.test.ts index d560c4b..c7bffc1 100644 --- a/packages/core/src/plugins/OverscanPlugin.test.ts +++ b/packages/core/src/plugins/__tests__/OverscanPlugin.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { OverscanPlugin } from "./OverscanPlugin"; -import { Range } from "../types"; +import { OverscanPlugin } from "../OverscanPlugin"; +import { Range } from "../../types"; describe("OverscanPlugin", () => { it("should initialize with default overscan", () => { diff --git a/packages/core/src/strategies/scroll/VirtualScrollSource.test.ts b/packages/core/src/strategies/scroll/__tests__/VirtualScrollSource.test.ts similarity index 97% rename from packages/core/src/strategies/scroll/VirtualScrollSource.test.ts rename to packages/core/src/strategies/scroll/__tests__/VirtualScrollSource.test.ts index 40f149d..21655a8 100644 --- a/packages/core/src/strategies/scroll/VirtualScrollSource.test.ts +++ b/packages/core/src/strategies/scroll/__tests__/VirtualScrollSource.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { VirtualScrollSource } from "./VirtualScrollSource"; +import { VirtualScrollSource } from "../VirtualScrollSource"; describe("VirtualScrollSource", () => { it("should initialize with default values", () => { diff --git a/packages/core/src/utils/calculateVirtualRange.test.ts b/packages/core/src/utils/__tests__/calculateVirtualRange.test.ts similarity index 98% rename from packages/core/src/utils/calculateVirtualRange.test.ts rename to packages/core/src/utils/__tests__/calculateVirtualRange.test.ts index 811b22e..48fd72d 100644 --- a/packages/core/src/utils/calculateVirtualRange.test.ts +++ b/packages/core/src/utils/__tests__/calculateVirtualRange.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { calculateVirtualRange } from "./calculateVirtualRange"; +import { calculateVirtualRange } from "../calculateVirtualRange"; describe("calculateVirtualRange", () => { const defaultParams = { diff --git a/packages/core/src/utils/clamp.test.ts b/packages/core/src/utils/__tests__/clamp.test.ts similarity index 97% rename from packages/core/src/utils/clamp.test.ts rename to packages/core/src/utils/__tests__/clamp.test.ts index 6eafa71..fc59ab9 100644 --- a/packages/core/src/utils/clamp.test.ts +++ b/packages/core/src/utils/__tests__/clamp.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { clamp } from "./clamp"; +import { clamp } from "../clamp"; describe("clamp", () => { it("returns value when within range", () => { diff --git a/packages/core/src/virtualizer/Virtualizer.test.ts b/packages/core/src/virtualizer/__test__/Virtualizer.test.ts similarity index 92% rename from packages/core/src/virtualizer/Virtualizer.test.ts rename to packages/core/src/virtualizer/__test__/Virtualizer.test.ts index 72d18b7..b9dce01 100644 --- a/packages/core/src/virtualizer/Virtualizer.test.ts +++ b/packages/core/src/virtualizer/__test__/Virtualizer.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { Virtualizer } from "./Virtualizer"; -import { FixedLayoutStrategy } from "../strategies/layout/FixedLayoutStrategy"; -import { VirtualScrollSource } from "../strategies/scroll/VirtualScrollSource"; -import { Plugin } from "../plugins/Plugin"; -import { VirtualizerState, Range } from "../types"; +import { Virtualizer } from "../Virtualizer"; +import { FixedLayoutStrategy } from "../../strategies/layout/FixedLayoutStrategy"; +import { VirtualScrollSource } from "../../strategies/scroll/VirtualScrollSource"; +import { Plugin } from "../../plugins/Plugin"; +import { VirtualizerState, Range } from "../../types"; describe("Virtualizer", () => { let layoutStrategy: FixedLayoutStrategy; From 092b610134cf82101c113de4f121faef41895d7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Tue, 23 Dec 2025 10:12:41 +0900 Subject: [PATCH 16/20] =?UTF-8?q?chore:=20ci=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 49 +++++++++++++++++++++++++- packages/core/vitest.config.ts | 2 +- packages/react-native/vitest.config.ts | 11 ++++++ packages/react/vitest.config.ts | 2 +- packages/shared/vitest.config.ts | 11 ++++++ 5 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 packages/react-native/vitest.config.ts create mode 100644 packages/shared/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbca478..6859fce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + pull-requests: write concurrency: group: ci-${{ github.ref }} @@ -56,9 +57,55 @@ jobs: - name: Test run: | - if pnpm run | grep -q "^ *test *"; then pnpm test; else echo "no test script"; fi + if pnpm run | grep -q "^ *test *"; then pnpm test -- --coverage; else echo "no test script"; fi pnpm --filter @scrolloop/react test:e2e + - name: Report Coverage + if: success() && github.event_name == 'pull_request' + uses: actions/github-script@v6 + with: + script: | + const fs = require('fs'); + const path = require('path'); + + const packagesDir = 'packages'; + if (!fs.existsSync(packagesDir)) return; + + const dirs = fs.readdirSync(packagesDir); + let message = '## 📊 Test Coverage Report\n\n'; + let hasCoverage = false; + + for (const dir of dirs) { + const summaryPath = path.join(packagesDir, dir, 'coverage', 'coverage-summary.json'); + if (fs.existsSync(summaryPath)) { + hasCoverage = true; + const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); + const total = summary.total; + + message += `### @scrolloop/${dir}\n\n`; + message += '| Type | % | Covered | Total |\n'; + message += '| :--- | :--- | :--- | :--- |\n'; + + const formatRow = (label, data) => { + return `| ${label} | ${data.pct}% | ${data.covered} | ${data.total} |`; + }; + + message += formatRow('Statements', total.statements) + '\n'; + message += formatRow('Branches', total.branches) + '\n'; + message += formatRow('Functions', total.functions) + '\n'; + message += formatRow('Lines', total.lines) + '\n\n'; + } + } + + if (hasCoverage) { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: message + }); + } + - name: Build run: pnpm run build diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 5e43252..25d976a 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ hookTimeout: 10000, coverage: { provider: "v8", - reporter: ["text", "json", "html"], + reporter: ["text", "json", "html", "json-summary"], exclude: [ "node_modules/", "dist/", diff --git a/packages/react-native/vitest.config.ts b/packages/react-native/vitest.config.ts new file mode 100644 index 0000000..1dd8712 --- /dev/null +++ b/packages/react-native/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + coverage: { + provider: "v8", + reporter: ["text", "json", "html", "json-summary"], + }, + }, +}); diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index d7f776e..ac71483 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -24,7 +24,7 @@ export default defineConfig({ }, coverage: { provider: "v8", - reporter: ["text", "json", "html"], + reporter: ["text", "json", "html", "json-summary"], exclude: [ "node_modules/", "dist/", diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 0000000..1dd8712 --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + coverage: { + provider: "v8", + reporter: ["text", "json", "html", "json-summary"], + }, + }, +}); From 0499b9dac1dbb0da68a947366df5c8035e7b0039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=9E=AC=EC=9A=B1?= Date: Tue, 23 Dec 2025 10:14:25 +0900 Subject: [PATCH 17/20] Update packages/react/playwright.config.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/react/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/playwright.config.ts b/packages/react/playwright.config.ts index edf2f6d..c3cf509 100644 --- a/packages/react/playwright.config.ts +++ b/packages/react/playwright.config.ts @@ -1,7 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ - testDir: "./src/__tests__/ssr", + testMatch: "**/*.e2e.test.ts", testMatch: "**/*.e2e.test.ts", fullyParallel: false, forbidOnly: !!process.env.CI, From dd186f941971f16fdbb8ffb45eb934f308475199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=A1=EC=9E=AC=EC=9A=B1?= Date: Tue, 23 Dec 2025 10:14:37 +0900 Subject: [PATCH 18/20] Update packages/react/src/__tests__/ssr/server.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/react/src/__tests__/ssr/server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/src/__tests__/ssr/server.ts b/packages/react/src/__tests__/ssr/server.ts index 9407c8f..a914ac7 100644 --- a/packages/react/src/__tests__/ssr/server.ts +++ b/packages/react/src/__tests__/ssr/server.ts @@ -35,6 +35,7 @@ try { clientBundle = result.outputFiles[0].text; } catch (e) { console.error("Failed to bundle client code:", e); + process.exit(1); } app.get("/bundle.js", (_req, res) => { From d7294422ca58db50e75bf0b474e5372a1ff87e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Tue, 23 Dec 2025 10:24:16 +0900 Subject: [PATCH 19/20] =?UTF-8?q?Chore:=20=ED=91=9C=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 59 ++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6859fce..fc3dc42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,39 +72,44 @@ jobs: if (!fs.existsSync(packagesDir)) return; const dirs = fs.readdirSync(packagesDir); - let message = '## 📊 Test Coverage Report\n\n'; - let hasCoverage = false; - for (const dir of dirs) { + const validPackages = dirs.filter(dir => { const summaryPath = path.join(packagesDir, dir, 'coverage', 'coverage-summary.json'); - if (fs.existsSync(summaryPath)) { - hasCoverage = true; + return fs.existsSync(summaryPath); + }); + + if (validPackages.length === 0) return; + + let message = '## 📊 Test Coverage Report (vitest) \n\n'; + + message += '| Type | ' + validPackages.map(pkg => `@scrolloop/${pkg}`).join(' | ') + ' |\n'; + message += '| :--- | ' + validPackages.map(() => ':---').join(' | ') + ' |\n'; + + const metrics = [ + { key: 'statements', label: 'Statements' }, + { key: 'branches', label: 'Branches' }, + { key: 'functions', label: 'Functions' }, + { key: 'lines', label: 'Lines' } + ]; + + for (const metric of metrics) { + message += `| **${metric.label}** |`; + + for (const pkg of validPackages) { + const summaryPath = path.join(packagesDir, pkg, 'coverage', 'coverage-summary.json'); const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); - const total = summary.total; - - message += `### @scrolloop/${dir}\n\n`; - message += '| Type | % | Covered | Total |\n'; - message += '| :--- | :--- | :--- | :--- |\n'; - - const formatRow = (label, data) => { - return `| ${label} | ${data.pct}% | ${data.covered} | ${data.total} |`; - }; - - message += formatRow('Statements', total.statements) + '\n'; - message += formatRow('Branches', total.branches) + '\n'; - message += formatRow('Functions', total.functions) + '\n'; - message += formatRow('Lines', total.lines) + '\n\n'; + const data = summary.total[metric.key]; + message += `${data.covered}/${data.total} (${data.pct}%) |`; } + message += '\n'; } - if (hasCoverage) { - await github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: message - }); - } + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: message + }); - name: Build run: pnpm run build From 1c5bb3a7fdd137833d15a59aa854271c19a48687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=AC=EC=9A=B1?= Date: Tue, 23 Dec 2025 10:28:59 +0900 Subject: [PATCH 20/20] =?UTF-8?q?Chore:=20=ED=91=9C=20=ED=98=95=EC=8B=9C?= =?UTF-8?q?=20=E3=84=B1=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc3dc42..1e09d53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,24 +82,21 @@ jobs: let message = '## 📊 Test Coverage Report (vitest) \n\n'; - message += '| Type | ' + validPackages.map(pkg => `@scrolloop/${pkg}`).join(' | ') + ' |\n'; - message += '| :--- | ' + validPackages.map(() => ':---').join(' | ') + ' |\n'; + message += '| Package | Statements | Branches | Functions | Lines |\n'; + message += '| :--- | :--- | :--- | :--- | :--- |\n'; - const metrics = [ - { key: 'statements', label: 'Statements' }, - { key: 'branches', label: 'Branches' }, - { key: 'functions', label: 'Functions' }, - { key: 'lines', label: 'Lines' } - ]; + const metrics = ['statements', 'branches', 'functions', 'lines']; - for (const metric of metrics) { - message += `| **${metric.label}** |`; + for (const pkg of validPackages) { + const summaryPath = path.join(packagesDir, pkg, 'coverage', 'coverage-summary.json'); + const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); + const total = summary.total; - for (const pkg of validPackages) { - const summaryPath = path.join(packagesDir, pkg, 'coverage', 'coverage-summary.json'); - const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8')); - const data = summary.total[metric.key]; - message += `${data.covered}/${data.total} (${data.pct}%) |`; + message += `| **@scrolloop/${pkg}** |`; + + for (const metric of metrics) { + const data = total[metric]; + message += ` ${data.covered}/${data.total} (${data.pct}%) |`; } message += '\n'; }