├── .all-contributorsrc ├── .env ├── .env.sample ├── .github ├── FUNDING.yml ├── actions │ └── prepare │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── build.yml │ ├── dependabot-auto-merge.yml │ ├── lint.yml │ ├── test.yml │ └── typecheck.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── eslint.config.mjs ├── index.html ├── mocks ├── browser.js ├── handlers.ts └── server.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.js ├── public ├── favicon.svg └── mockServiceWorker.js ├── scripts ├── remove_tailwind.js └── validate ├── src ├── @types │ ├── __tests__ │ │ └── URLType.test.ts │ ├── app.d.ts │ └── utility.d.ts ├── App.test.tsx ├── App.tsx ├── components │ ├── Box │ │ ├── index.module.css │ │ └── index.tsx │ ├── ErrorBoundary.tsx │ ├── Layout │ │ ├── Layout.module.css │ │ └── Layout.tsx │ └── Spinner.tsx ├── global.css ├── hooks │ ├── useUpdateEffect.test.ts │ └── useUpdateEffect.ts ├── logo.svg ├── main.tsx ├── pages │ ├── Index │ │ ├── Counter.tsx │ │ ├── DocList.module.css │ │ ├── DocList.tsx │ │ ├── index.module.css │ │ └── index.tsx │ └── Notfound │ │ ├── index.module.css │ │ └── index.tsx ├── router.tsx ├── setupTests.ts └── vite-env.d.ts ├── tsconfig.json ├── vite.config.ts └── vitest.config.ts /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "create-react-app-vite", 3 | "projectOwner": "laststance", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "none", 12 | "contributors": [ 13 | { 14 | "login": "ryota-murakami", 15 | "name": "ryota-murakami", 16 | "avatar_url": "https://avatars1.githubusercontent.com/u/5501268?s=400&u=7bf6b1580b95930980af2588ef0057f3e9ec1ff8&v=4", 17 | "profile": "http://ryota-murakami.github.io/", 18 | "contributions": [ 19 | "code", 20 | "doc", 21 | "test" 22 | ] 23 | }, 24 | { 25 | "login": "nvh95", 26 | "name": "Hung Viet Nguyen", 27 | "avatar_url": "https://avatars.githubusercontent.com/u/8603085?v=4", 28 | "profile": "https://hung.dev", 29 | "contributions": [ 30 | "code" 31 | ] 32 | }, 33 | { 34 | "login": "shayc", 35 | "name": "Shay Cojocaru", 36 | "avatar_url": "https://avatars.githubusercontent.com/u/6969966?v=4", 37 | "profile": "https://github.com/shayc", 38 | "contributions": [ 39 | "doc" 40 | ] 41 | }, 42 | { 43 | "login": "NateAGeek", 44 | "name": "NateAGeek", 45 | "avatar_url": "https://avatars.githubusercontent.com/u/1813055?v=4", 46 | "profile": "https://github.com/NateAGeek", 47 | "contributions": [ 48 | "bug" 49 | ] 50 | } 51 | ], 52 | "contributorsPerLine": 7, 53 | "skipCi": true, 54 | "commitType": "docs" 55 | } 56 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_TEXT="I'm REACT_APP_TEXT from .env" -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | REACT_APP_TEXT="I'm REACT_APP_TEXT from .env" -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ryota-murakami 4 | -------------------------------------------------------------------------------- /.github/actions/prepare/action.yml: -------------------------------------------------------------------------------- 1 | description: Prepares the repo for a typical CI job 2 | 3 | name: Prepare 4 | 5 | runs: 6 | steps: 7 | - name: Install pnpm 8 | uses: pnpm/action-setup@v4 9 | with: 10 | version: 9 11 | - name: Use Node.js 12 | uses: actions/setup-node@v4 13 | with: 14 | node-version: '20' 15 | cache: 'pnpm' 16 | - name: Install dependencies 17 | run: pnpm install 18 | shell: bash 19 | using: composite 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | # Files stored in `app` directory 5 | directory: '/' 6 | schedule: 7 | interval: 'daily' 8 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: ./.github/actions/prepare 15 | - run: pnpm build 16 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Auto merge 2 | on: pull_request 3 | jobs: 4 | merge: 5 | if: ${{ github.actor == 'dependabot[bot]' }} 6 | runs-on: ubuntu-latest 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | env: 11 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 12 | steps: 13 | - uses: actions/checkout@v4 14 | - run: gh pr merge "${GITHUB_HEAD_REF}" --merge --auto 15 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: ./.github/actions/prepare 15 | - run: pnpm lint 16 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: ./.github/actions/prepare 15 | - run: pnpm test 16 | -------------------------------------------------------------------------------- /.github/workflows/typecheck.yml: -------------------------------------------------------------------------------- 1 | name: Typecheck 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | typecheck: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: ./.github/actions/prepare 15 | - run: pnpm typecheck 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | .env -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | .eslintignore 4 | .gitignore 5 | LICENSE 6 | build 7 | dist 8 | dist-ssr 9 | .all-contributorsrc 10 | .vscode 11 | .idea -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Laststance.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Create React App Vite [![Typecheck](https://github.com/laststance/create-react-app-vite/actions/workflows/typecheck.yml/badge.svg)](https://github.com/laststance/vite-react-ts-alter/actions/workflows/typecheck.yml) [![Test](https://github.com/laststance/create-react-app-vite/actions/workflows/test.yml/badge.svg)](https://github.com/laststance/create-react-app-vite/actions/workflows/test.yml) [![Build](https://github.com/laststance/create-react-app-vite/actions/workflows/build.yml/badge.svg)](https://github.com/laststance/create-react-app-vite/actions/workflows/build.yml) [![Lint](https://github.com/laststance/create-react-app-vite/actions/workflows/lint.yml/badge.svg)](https://github.com/laststance/create-react-app-vite/actions/workflows/lint.yml) 2 | 3 | > Simple CRA style Vite teimpate. 4 | > Create plain and lightweight React+TS programming environment. 5 | > And a easy migration base for create-react-app to Vite. 6 | 7 | ## [Try this Online!](https://codesandbox.io/p/github/laststance/create-react-app-vite/main?file=%2FREADME.md&workspace=%257B%2522activeFileId%2522%253A%2522clfgsr6q10016g2hjg3xq06lt%2522%252C%2522openFiles%2522%253A%255B%2522%252FREADME.md%2522%255D%252C%2522sidebarPanel%2522%253A%2522EXPLORER%2522%252C%2522gitSidebarPanel%2522%253A%2522COMMIT%2522%252C%2522spaces%2522%253A%257B%2522clfgsra1u000x3b6mbdjl3ahb%2522%253A%257B%2522key%2522%253A%2522clfgsra1u000x3b6mbdjl3ahb%2522%252C%2522name%2522%253A%2522Default%2522%252C%2522devtools%2522%253A%255B%257B%2522key%2522%253A%2522clfgsra1u000y3b6meoz3zcev%2522%252C%2522type%2522%253A%2522PROJECT_SETUP%2522%252C%2522isMinimized%2522%253Afalse%257D%252C%257B%2522type%2522%253A%2522PREVIEW%2522%252C%2522taskId%2522%253A%2522dev%2522%252C%2522port%2522%253A5173%252C%2522key%2522%253A%2522clfgss4o700dz3b6mz869sru3%2522%252C%2522isMinimized%2522%253Afalse%257D%252C%257B%2522type%2522%253A%2522TASK_LOG%2522%252C%2522taskId%2522%253A%2522dev%2522%252C%2522key%2522%253A%2522clfgss3ug00ba3b6mpaataz0k%2522%252C%2522isMinimized%2522%253Afalse%257D%255D%257D%257D%252C%2522currentSpace%2522%253A%2522clfgsra1u000x3b6mbdjl3ahb%2522%252C%2522spacesOrder%2522%253A%255B%2522clfgsra1u000x3b6mbdjl3ahb%2522%255D%252C%2522hideCodeEditor%2522%253Afalse%257D) 8 | 9 | This is a Vite template top of the official [Vite](https://vitejs.dev/) [react-ts](https://stackblitz.com/edit/vitejs-vite-is3dmk?file=index.html&terminal=dev) template(`npm init vite@latest myapp -- --template react-ts`) and some extended setup. 10 | I'd like to keep CRA like experience as much as possible, So improving/adding feature Rull Request is really welcome! 11 | 12 | - Support CRA's [Custom Environment Variables](https://create-react-app.dev/docs/adding-custom-environment-variables/) like `REACT_APP_`. 13 | - [eslint-config-ts-prefixer](https://github.com/laststance/eslint-config-ts-prefixer). Specialized fixable(`--fix` option) rule sets. Zero extend any recommended for confortable DX. 14 | - [Vitest](https://vitest.dev/), [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/), [MSW](https://mswjs.io/) 15 | - [tailwindcss](https://tailwindcss.com/) 16 | - [Github Actions](https://github.com/features/actions) 17 | 18 | All npm package are keeping least release version powered by [Dependabot](https://github.com/dependabot). 19 | 20 | # Installation 21 | 22 | ``` 23 | npx tiged laststance/create-react-app-vite myapp 24 | ``` 25 | 26 | ### pnpm 27 | 28 | ```sh 29 | cd myapp 30 | pnpm install 31 | pnpm validate 32 | pnpm start 33 | ``` 34 | 35 | If you don't need TailwindCSS, run `pnpm remove:tailwind` after npm installed. 36 | 37 | ### Commands 38 | 39 | ```sh 40 | pnpm dev # start development server 41 | pnpm start # start development server 42 | pnpm validate # run test,lint,build,typecheck concurrently 43 | pnpm test # run jest 44 | pnpm lint # run eslint 45 | pnpm lint:fix # run eslint with --fix option 46 | pnpm typecheck # run TypeScript compiler check 47 | pnpm build # build production bundle to 'dist' directly 48 | pnpm prettier # run prettier for json|yml|css|md|mdx files 49 | pnpm clean # remove 'node_modules' 'yarn.lock' 'dist' completely 50 | pnpm serve # launch server for production bundle in local 51 | pnpm remove:tailwind # remove TailwindCSS 52 | ``` 53 | 54 | # CRA to Vite migration guides 55 | 56 | - [Migrate to Vite from Create React App (CRA)](https://www.robinwieruch.de/vite-create-react-app/) 57 | - [Migrating from Create React App (CRA) to Vite](https://cathalmacdonnacha.com/migrating-from-create-react-app-cra-to-vite) 58 | - [Migrating a Create React App (CRA) application to Vite](https://www.darraghoriordan.com/2021/05/16/migrating-from-create-react-app-to-vite) 59 | 60 | # Background 61 | 62 | Simply put, CRA development has stopped as of 2023. 63 | This has sparked a discussion about replacing CRA with Vite for official documentation recommendations. 64 | [Replace Create React App recommendation with Vite](https://github.com/reactjs/react.dev/pull/5487) 65 | Dan Abramov offered some plans for the future of the CRA in his comments, but no direct answers were given. 66 | https://github.com/reactjs/react.dev/pull/5487#issuecomment-1409720741 67 | 68 | The React community is still buzzing around Server Component after May 2023, but there is still a high demand for the React SinglePageAplication starter that the CRA has served in the past, and I was one of the people who needed it, I was one of the people who needed it, so I decided to create a template in Vite that could be used as much as possible like CRA. 69 | 70 | # License 71 | 72 | MIT 73 | 74 | ## Contributors ✨ 75 | 76 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 |
ryota-murakami
ryota-murakami

💻 📖 ⚠️
Hung Viet Nguyen
Hung Viet Nguyen

💻
Shay Cojocaru
Shay Cojocaru

📖
NateAGeek
NateAGeek

🐛
91 | 92 | 93 | 94 | 95 | 96 | 97 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 98 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | 4 | import { fixupPluginRules } from '@eslint/compat' 5 | import { FlatCompat } from '@eslint/eslintrc' 6 | import js from '@eslint/js' 7 | import tsParser from '@typescript-eslint/parser' 8 | import jsxA11Y from 'eslint-plugin-jsx-a11y' 9 | import reactHooks from 'eslint-plugin-react-hooks' 10 | 11 | const __filename = fileURLToPath(import.meta.url) 12 | const __dirname = path.dirname(__filename) 13 | const compat = new FlatCompat({ 14 | baseDirectory: __dirname, 15 | recommendedConfig: js.configs.recommended, 16 | allConfig: js.configs.all, 17 | }) 18 | 19 | export default [ 20 | { 21 | ignores: [ 22 | '**/.vscode', 23 | '**/node_modules', 24 | '**/build', 25 | '**/dist', 26 | '**/.github', 27 | '**/.idea', 28 | 'public/mockServiceWorker.js', 29 | ], 30 | }, 31 | ...compat.extends('ts-prefixer', 'plugin:jsx-a11y/recommended'), 32 | { 33 | plugins: { 34 | 'react-hooks': fixupPluginRules(reactHooks), 35 | 'jsx-a11y': jsxA11Y, 36 | }, 37 | 38 | languageOptions: { 39 | globals: {}, 40 | parser: tsParser, 41 | ecmaVersion: 5, 42 | sourceType: 'script', 43 | 44 | parserOptions: { 45 | project: ['tsconfig.json'], 46 | }, 47 | }, 48 | 49 | settings: {}, 50 | 51 | rules: { 52 | 'react-hooks/rules-of-hooks': 'error', 53 | }, 54 | }, 55 | ] 56 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Create React App 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /mocks/browser.js: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw/browser' 2 | 3 | import { handlers } from './handlers' 4 | 5 | // This configures a Service Worker with the given request handlers. 6 | export const worker = setupWorker(...handlers) 7 | -------------------------------------------------------------------------------- /mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { http, HttpResponse } from 'msw' 2 | 3 | const sleep = async (ms: number): Promise => 4 | new Promise((resolve) => setTimeout(resolve, ms)) 5 | 6 | export const handlers = [ 7 | http.get('http://localhost:3000/api/doclist', async () => { 8 | const data: DocList = [ 9 | { name: 'React', url: 'https://react.dev/' }, 10 | { name: 'Vite', url: 'https://vitejs.dev/' }, 11 | { 12 | name: 'React Router', 13 | url: 'https://reactrouter.com/en/main/start/overview', 14 | }, 15 | { name: 'MSW', url: 'https://mswjs.io/' }, 16 | { name: 'Tailwind CSS', url: 'https://tailwindcss.com/' }, 17 | ] 18 | 19 | await sleep(2000) 20 | 21 | return HttpResponse.json(data) 22 | }), 23 | ] 24 | -------------------------------------------------------------------------------- /mocks/server.js: -------------------------------------------------------------------------------- 1 | // from https://github.com/mswjs/examples/blob/master/examples/with-jest/src/mocks/server.js 2 | import { setupServer } from 'msw/node' 3 | 4 | import { handlers } from './handlers' 5 | 6 | export const server = setupServer(...handlers) 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-react-app-vite", 3 | "version": "0.4.0", 4 | "license": "MIT", 5 | "msw": { 6 | "workerDirectory": "public" 7 | }, 8 | "lint-staged": { 9 | "*": "prettier --ignore-unknown --write" 10 | }, 11 | "scripts": { 12 | "start": "vite", 13 | "dev": "vite", 14 | "build": "vite build", 15 | "preview": "vite preview", 16 | "test": "vitest --run", 17 | "test:ui": "vitest --ui", 18 | "test:watch": "vitest", 19 | "lint": "eslint . -c eslint.config.mjs", 20 | "lint:fix": "eslint . -c eslint.config.mjs --fix", 21 | "typecheck": "tsc --noEmit", 22 | "prettier": "prettier --ignore-unknown --write .", 23 | "clean": "rimraf node_modules pnpm-lock.yaml dist", 24 | "validate": "./scripts/validate", 25 | "remove:tailwind": "scripts/remove_tailwind.js", 26 | "prepare": "husky" 27 | }, 28 | "dependencies": { 29 | "clsx": "^2.1.1", 30 | "react": "^19.1.0", 31 | "react-dom": "^19.1.0", 32 | "react-router": "^7.6.2" 33 | }, 34 | "devDependencies": { 35 | "@eslint/compat": "^1.2.9", 36 | "@eslint/eslintrc": "^3.3.1", 37 | "@eslint/js": "^9.27.0", 38 | "@tailwindcss/aspect-ratio": "^0.4.2", 39 | "@tailwindcss/forms": "^0.5.10", 40 | "@tailwindcss/line-clamp": "^0.4.4", 41 | "@tailwindcss/postcss": "^4.1.8", 42 | "@tailwindcss/typography": "^0.5.16", 43 | "@testing-library/dom": "^10.4.0", 44 | "@testing-library/jest-dom": "^6.6.3", 45 | "@testing-library/react": "^16.3.0", 46 | "@testing-library/user-event": "^14.6.1", 47 | "@types/eslint": "^9.6.1", 48 | "@types/react": "^19.1.6", 49 | "@types/react-dom": "^19.1.6", 50 | "@typescript-eslint/eslint-plugin": "^8.33.0", 51 | "@typescript-eslint/parser": "^8.32.1", 52 | "@vitejs/plugin-react-swc": "^3.10.0", 53 | "@vitest/ui": "^3.1.1", 54 | "all-contributors-cli": "^6.26.1", 55 | "concurrently": "^9.1.2", 56 | "cross-fetch": "^4.1.0", 57 | "eslint": "^9.27.0", 58 | "eslint-config-ts-prefixer": "^1.14.2", 59 | "eslint-import-resolver-typescript": "^4.4.1", 60 | "eslint-plugin-import": "^2.31.0", 61 | "eslint-plugin-jsx-a11y": "^6.10.2", 62 | "eslint-plugin-prettier": "^5.4.1", 63 | "eslint-plugin-react-hooks": "^5.2.0", 64 | "husky": "^9.1.7", 65 | "jsdom": "^26.1.0", 66 | "lint-staged": "^16.0.0", 67 | "msw": "^2.9.0", 68 | "postcss": "^8.5.3", 69 | "prettier": "^3.5.3", 70 | "prettier-plugin-tailwindcss": "^0.6.11", 71 | "rimraf": "^6.0.1", 72 | "tailwindcss": "^4.1.7", 73 | "ts-expect": "^1.3.0", 74 | "typescript": "^5.8.3", 75 | "vite": "^6.3.5", 76 | "vite-plugin-environment": "^1.1.3", 77 | "vite-plugin-svgr": "^4.3.0", 78 | "vitest": "^3.2.0" 79 | }, 80 | "pnpm": { 81 | "onlyBuiltDependencies": [ 82 | "@swc/core", 83 | "esbuild", 84 | "msw", 85 | "unrs-resolver" 86 | ] 87 | }, 88 | "volta": { 89 | "node": "22.16.0" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('prettier-plugin-tailwindcss')], 3 | tailwindConfig: './tailwind.config.js', 4 | } 5 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | 4 | /** 5 | * Mock Service Worker. 6 | * @see https://github.com/mswjs/msw 7 | * - Please do NOT modify this file. 8 | */ 9 | 10 | const PACKAGE_VERSION = '2.9.0' 11 | const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af' 12 | const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') 13 | const activeClientIds = new Set() 14 | 15 | addEventListener('install', function () { 16 | self.skipWaiting() 17 | }) 18 | 19 | addEventListener('activate', function (event) { 20 | event.waitUntil(self.clients.claim()) 21 | }) 22 | 23 | addEventListener('message', async function (event) { 24 | const clientId = Reflect.get(event.source || {}, 'id') 25 | 26 | if (!clientId || !self.clients) { 27 | return 28 | } 29 | 30 | const client = await self.clients.get(clientId) 31 | 32 | if (!client) { 33 | return 34 | } 35 | 36 | const allClients = await self.clients.matchAll({ 37 | type: 'window', 38 | }) 39 | 40 | switch (event.data) { 41 | case 'KEEPALIVE_REQUEST': { 42 | sendToClient(client, { 43 | type: 'KEEPALIVE_RESPONSE', 44 | }) 45 | break 46 | } 47 | 48 | case 'INTEGRITY_CHECK_REQUEST': { 49 | sendToClient(client, { 50 | type: 'INTEGRITY_CHECK_RESPONSE', 51 | payload: { 52 | packageVersion: PACKAGE_VERSION, 53 | checksum: INTEGRITY_CHECKSUM, 54 | }, 55 | }) 56 | break 57 | } 58 | 59 | case 'MOCK_ACTIVATE': { 60 | activeClientIds.add(clientId) 61 | 62 | sendToClient(client, { 63 | type: 'MOCKING_ENABLED', 64 | payload: { 65 | client: { 66 | id: client.id, 67 | frameType: client.frameType, 68 | }, 69 | }, 70 | }) 71 | break 72 | } 73 | 74 | case 'MOCK_DEACTIVATE': { 75 | activeClientIds.delete(clientId) 76 | break 77 | } 78 | 79 | case 'CLIENT_CLOSED': { 80 | activeClientIds.delete(clientId) 81 | 82 | const remainingClients = allClients.filter((client) => { 83 | return client.id !== clientId 84 | }) 85 | 86 | // Unregister itself when there are no more clients 87 | if (remainingClients.length === 0) { 88 | self.registration.unregister() 89 | } 90 | 91 | break 92 | } 93 | } 94 | }) 95 | 96 | addEventListener('fetch', function (event) { 97 | // Bypass navigation requests. 98 | if (event.request.mode === 'navigate') { 99 | return 100 | } 101 | 102 | // Opening the DevTools triggers the "only-if-cached" request 103 | // that cannot be handled by the worker. Bypass such requests. 104 | if ( 105 | event.request.cache === 'only-if-cached' && 106 | event.request.mode !== 'same-origin' 107 | ) { 108 | return 109 | } 110 | 111 | // Bypass all requests when there are no active clients. 112 | // Prevents the self-unregistered worked from handling requests 113 | // after it's been deleted (still remains active until the next reload). 114 | if (activeClientIds.size === 0) { 115 | return 116 | } 117 | 118 | const requestId = crypto.randomUUID() 119 | event.respondWith(handleRequest(event, requestId)) 120 | }) 121 | 122 | /** 123 | * @param {FetchEvent} event 124 | * @param {string} requestId 125 | */ 126 | async function handleRequest(event, requestId) { 127 | const client = await resolveMainClient(event) 128 | const requestCloneForEvents = event.request.clone() 129 | const response = await getResponse(event, client, requestId) 130 | 131 | // Send back the response clone for the "response:*" life-cycle events. 132 | // Ensure MSW is active and ready to handle the message, otherwise 133 | // this message will pend indefinitely. 134 | if (client && activeClientIds.has(client.id)) { 135 | const serializedRequest = await serializeRequest(requestCloneForEvents) 136 | 137 | // Clone the response so both the client and the library could consume it. 138 | const responseClone = response.clone() 139 | 140 | sendToClient( 141 | client, 142 | { 143 | type: 'RESPONSE', 144 | payload: { 145 | isMockedResponse: IS_MOCKED_RESPONSE in response, 146 | request: { 147 | id: requestId, 148 | ...serializedRequest, 149 | }, 150 | response: { 151 | type: responseClone.type, 152 | status: responseClone.status, 153 | statusText: responseClone.statusText, 154 | headers: Object.fromEntries(responseClone.headers.entries()), 155 | body: responseClone.body, 156 | }, 157 | }, 158 | }, 159 | responseClone.body ? [serializedRequest.body, responseClone.body] : [], 160 | ) 161 | } 162 | 163 | return response 164 | } 165 | 166 | /** 167 | * Resolve the main client for the given event. 168 | * Client that issues a request doesn't necessarily equal the client 169 | * that registered the worker. It's with the latter the worker should 170 | * communicate with during the response resolving phase. 171 | * @param {FetchEvent} event 172 | * @returns {Promise} 173 | */ 174 | async function resolveMainClient(event) { 175 | const client = await self.clients.get(event.clientId) 176 | 177 | if (activeClientIds.has(event.clientId)) { 178 | return client 179 | } 180 | 181 | if (client?.frameType === 'top-level') { 182 | return client 183 | } 184 | 185 | const allClients = await self.clients.matchAll({ 186 | type: 'window', 187 | }) 188 | 189 | return allClients 190 | .filter((client) => { 191 | // Get only those clients that are currently visible. 192 | return client.visibilityState === 'visible' 193 | }) 194 | .find((client) => { 195 | // Find the client ID that's recorded in the 196 | // set of clients that have registered the worker. 197 | return activeClientIds.has(client.id) 198 | }) 199 | } 200 | 201 | /** 202 | * @param {FetchEvent} event 203 | * @param {Client | undefined} client 204 | * @param {string} requestId 205 | * @returns {Promise} 206 | */ 207 | async function getResponse(event, client, requestId) { 208 | // Clone the request because it might've been already used 209 | // (i.e. its body has been read and sent to the client). 210 | const requestClone = event.request.clone() 211 | 212 | function passthrough() { 213 | // Cast the request headers to a new Headers instance 214 | // so the headers can be manipulated with. 215 | const headers = new Headers(requestClone.headers) 216 | 217 | // Remove the "accept" header value that marked this request as passthrough. 218 | // This prevents request alteration and also keeps it compliant with the 219 | // user-defined CORS policies. 220 | const acceptHeader = headers.get('accept') 221 | if (acceptHeader) { 222 | const values = acceptHeader.split(',').map((value) => value.trim()) 223 | const filteredValues = values.filter( 224 | (value) => value !== 'msw/passthrough', 225 | ) 226 | 227 | if (filteredValues.length > 0) { 228 | headers.set('accept', filteredValues.join(', ')) 229 | } else { 230 | headers.delete('accept') 231 | } 232 | } 233 | 234 | return fetch(requestClone, { headers }) 235 | } 236 | 237 | // Bypass mocking when the client is not active. 238 | if (!client) { 239 | return passthrough() 240 | } 241 | 242 | // Bypass initial page load requests (i.e. static assets). 243 | // The absence of the immediate/parent client in the map of the active clients 244 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 245 | // and is not ready to handle requests. 246 | if (!activeClientIds.has(client.id)) { 247 | return passthrough() 248 | } 249 | 250 | // Notify the client that a request has been intercepted. 251 | const serializedRequest = await serializeRequest(event.request) 252 | const clientMessage = await sendToClient( 253 | client, 254 | { 255 | type: 'REQUEST', 256 | payload: { 257 | id: requestId, 258 | ...serializedRequest, 259 | }, 260 | }, 261 | [serializedRequest.body], 262 | ) 263 | 264 | switch (clientMessage.type) { 265 | case 'MOCK_RESPONSE': { 266 | return respondWithMock(clientMessage.data) 267 | } 268 | 269 | case 'PASSTHROUGH': { 270 | return passthrough() 271 | } 272 | } 273 | 274 | return passthrough() 275 | } 276 | 277 | /** 278 | * @param {Client} client 279 | * @param {any} message 280 | * @param {Array} transferrables 281 | * @returns {Promise} 282 | */ 283 | function sendToClient(client, message, transferrables = []) { 284 | return new Promise((resolve, reject) => { 285 | const channel = new MessageChannel() 286 | 287 | channel.port1.onmessage = (event) => { 288 | if (event.data && event.data.error) { 289 | return reject(event.data.error) 290 | } 291 | 292 | resolve(event.data) 293 | } 294 | 295 | client.postMessage(message, [ 296 | channel.port2, 297 | ...transferrables.filter(Boolean), 298 | ]) 299 | }) 300 | } 301 | 302 | /** 303 | * @param {Response} response 304 | * @returns {Response} 305 | */ 306 | function respondWithMock(response) { 307 | // Setting response status code to 0 is a no-op. 308 | // However, when responding with a "Response.error()", the produced Response 309 | // instance will have status code set to 0. Since it's not possible to create 310 | // a Response instance with status code 0, handle that use-case separately. 311 | if (response.status === 0) { 312 | return Response.error() 313 | } 314 | 315 | const mockedResponse = new Response(response.body, response) 316 | 317 | Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { 318 | value: true, 319 | enumerable: true, 320 | }) 321 | 322 | return mockedResponse 323 | } 324 | 325 | /** 326 | * @param {Request} request 327 | */ 328 | async function serializeRequest(request) { 329 | return { 330 | url: request.url, 331 | mode: request.mode, 332 | method: request.method, 333 | headers: Object.fromEntries(request.headers.entries()), 334 | cache: request.cache, 335 | credentials: request.credentials, 336 | destination: request.destination, 337 | integrity: request.integrity, 338 | redirect: request.redirect, 339 | referrer: request.referrer, 340 | referrerPolicy: request.referrerPolicy, 341 | body: await request.arrayBuffer(), 342 | keepalive: request.keepalive, 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /scripts/remove_tailwind.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { execSync } = require('node:child_process') 4 | const fs = require('node:fs') 5 | const path = require('node:path') 6 | const { chdir, exit } = require('node:process') 7 | 8 | const rootDir = path.join(__dirname, '..') 9 | 10 | function removeTailwind() { 11 | try { 12 | fs.unlinkSync(rootDir + '/tailwind.config.js') 13 | console.log('remove tailwind.config.js\n') 14 | } catch (e) { 15 | if (e.message.includes('no such file or directory')) 16 | console.log('tailwind.config.js has already been removed.\n') 17 | } 18 | 19 | // npm unlinstall 20 | const packageJson = require(rootDir + '/package.json') 21 | 22 | const dependencies = packageJson.dependencies || {} 23 | const devDependencies = packageJson.devDependencies || {} 24 | 25 | const tailwindPackages = [] 26 | 27 | for (const dep in dependencies) { 28 | if (dep.includes('tailwind')) { 29 | tailwindPackages.push(dep) 30 | } 31 | } 32 | 33 | for (const dep in devDependencies) { 34 | if (dep.includes('tailwind')) { 35 | tailwindPackages.push(dep) 36 | } 37 | } 38 | 39 | if (tailwindPackages.length === 0) { 40 | console.log('TailwindCSS has already been removed.\n') 41 | exit() 42 | } 43 | 44 | const uninstallCommand = 'npm uninstall ' + tailwindPackages.join(' ') 45 | 46 | chdir(rootDir) 47 | // execSync return null when command successful 48 | const res = execSync(uninstallCommand, { 49 | stdio: [0, 1, 2], 50 | }) 51 | 52 | if (res !== null && res.status !== 0) 53 | console.error('Command Failed: ' + uninstallCommand) 54 | 55 | console.log(tailwindPackages.join('\n')) 56 | console.log('Above packages uninstall has been successful.\n') 57 | console.log() 58 | console.log('Completed remove TailwindCSS.\n') 59 | } 60 | 61 | // run 62 | removeTailwind() 63 | -------------------------------------------------------------------------------- /scripts/validate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | concurrently \ 4 | --kill-others-on-fail \ 5 | --prefix "[{name}]" \ 6 | --names "test,lint:fix,typecheck,build" \ 7 | --prefix-colors "bgRed.bold.white,bgGreen.bold.white,bgBlue.bold.white,bgMagenta.bold.white" \ 8 | "npm run test --silent -- --watch=false" \ 9 | "npm run lint:fix --silent" \ 10 | "npm run typecheck --silent" \ 11 | "npm run build --silent" 12 | -------------------------------------------------------------------------------- /src/@types/__tests__/URLType.test.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'ts-expect' 2 | 3 | expectType('http://example.com') 4 | expectType('https://example.com/news') 5 | // @ts-expect-error give non URL 6 | expectType('example.com/news') 7 | -------------------------------------------------------------------------------- /src/@types/app.d.ts: -------------------------------------------------------------------------------- 1 | declare interface Doc { 2 | name: string 3 | url: URLType 4 | } 5 | declare type DocList = Doc[] 6 | -------------------------------------------------------------------------------- /src/@types/utility.d.ts: -------------------------------------------------------------------------------- 1 | import type { Dispatch, SetStateAction } from 'react' 2 | 3 | declare global { 4 | type _ = any 5 | 6 | // @see https://youtu.be/QSIXYMIJkQg?si=CyycYgaAGNZCEuYj&t=188 7 | type TODO = any 8 | 9 | type AnyFunction = (...args: any[]) => any 10 | 11 | type URLType = `http${'s' | ''}://${string}.${string}` 12 | 13 | // https://stackoverflow.com/a/69288824/8440230 14 | type Expand = T extends (...args: infer A) => infer R 15 | ? (...args: Expand) => Expand 16 | : T extends infer O 17 | ? { [K in keyof O]: O[K] } 18 | : never 19 | 20 | type ExpandRecursively = T extends (...args: infer A) => infer R 21 | ? (...args: ExpandRecursively) => ExpandRecursively 22 | : T extends object 23 | ? T extends infer O 24 | ? { [K in keyof O]: ExpandRecursively } 25 | : never 26 | : T 27 | } 28 | 29 | declare module 'react' { 30 | type SetState = Dispatch> 31 | } 32 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from '@testing-library/react' 2 | import userEvent from '@testing-library/user-event' 3 | import React from 'react' 4 | 5 | import App from './App' 6 | 7 | test('Work App Component without error', () => { 8 | render() 9 | 10 | expect(screen.getByText("I'm REACT_APP_TEXT from .env")).toBeInTheDocument() 11 | }) 12 | 13 | test('Working Counter', async () => { 14 | const user = userEvent.setup() 15 | const { getByText } = render() 16 | expect(getByText('count is: 0')).toBeInTheDocument() 17 | 18 | const button = getByText(/count is: \d/) 19 | 20 | await user.click(button) 21 | expect(getByText('count is: 1')).toBeInTheDocument() 22 | 23 | await user.click(button) 24 | expect(getByText('count is: 2')).toBeInTheDocument() 25 | 26 | await user.click(button) 27 | expect(getByText('count is: 3')).toBeInTheDocument() 28 | }) 29 | 30 | test('working with msw', async () => { 31 | render() 32 | 33 | await waitFor( 34 | () => { 35 | expect(screen.getByText('MSW')).toBeInTheDocument() 36 | expect(screen.getByText('Tailwind CSS')).toBeInTheDocument() 37 | }, 38 | { timeout: 5000 }, 39 | ) 40 | }) 41 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { RouterProvider } from 'react-router' 3 | 4 | import ErrorBoundary from './components/ErrorBoundary' 5 | import router from './router' 6 | 7 | const App: React.FC = () => ( 8 | 9 | 10 | 11 | ) 12 | App.displayName = 'App' 13 | export default App 14 | -------------------------------------------------------------------------------- /src/components/Box/index.module.css: -------------------------------------------------------------------------------- 1 | .box { 2 | display: grid; 3 | place-content: center; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/Box/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import type { PropsWithChildren } from 'react' 3 | 4 | import styles from './index.module.css' 5 | 6 | const Box: React.FC = ({ children, ...rest }) => { 7 | return ( 8 |
9 | {children} 10 |
11 | ) 12 | } 13 | 14 | export default Box 15 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import type { ErrorInfo, ReactNode } from 'react' 2 | import React, { Component } from 'react' 3 | 4 | interface Props { 5 | children?: ReactNode 6 | } 7 | 8 | interface State { 9 | error: Error | null 10 | info: ErrorInfo | null 11 | } 12 | class ErrorBoundary extends Component { 13 | state = { 14 | error: null, 15 | info: null, 16 | } 17 | 18 | componentDidCatch(error: Error, info: ErrorInfo): void { 19 | this.setState({ error, info }) 20 | } 21 | 22 | render(): ReactNode { 23 | const { error } = this.state 24 | if (error) { 25 | //Sentry.captureException(error) 26 | return 27 | } 28 | return this.props.children 29 | } 30 | } 31 | 32 | export default ErrorBoundary 33 | 34 | const LayoutStyle: React.CSSProperties = { 35 | alignItems: 'center', 36 | display: 'flex', 37 | justifyContent: 'center', 38 | minHeight: '100vh', 39 | minWidth: '100%', 40 | } 41 | 42 | const MessageStyle: React.CSSProperties = { 43 | border: '2px #78909c solid', 44 | borderRadius: '5px', 45 | color: '#78909c', 46 | fontSize: '24px', 47 | padding: '40px', 48 | } 49 | 50 | export const ErrorBoundaryFallbackComponent: React.FC< 51 | React.PropsWithChildren 52 | > = () => ( 53 |
54 |
55 | Something Error Ooccurring 56 | 57 | 😞 58 | 59 |
60 |
61 | ) 62 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.module.css: -------------------------------------------------------------------------------- 1 | .layout { 2 | min-height: 100vh; 3 | min-width: 100vw; 4 | background-color: #282c34; 5 | font-size: calc(10px + 2vmin); 6 | color: white; 7 | display: grid; 8 | place-items: center; 9 | } 10 | 11 | .container { 12 | min-height: 100vh; 13 | min-width: 90%; 14 | max-width: 90%; 15 | display: flex; 16 | flex-direction: column; 17 | gap: 20px; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from 'react' 2 | import React from 'react' 3 | 4 | import styles from './Layout.module.css' 5 | 6 | const Layout: React.FC = ({ children, ...rest }) => { 7 | return ( 8 |
9 |
{children}
10 |
11 | ) 12 | } 13 | 14 | export default Layout 15 | -------------------------------------------------------------------------------- /src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import React from 'react' 3 | 4 | const sizes = { 5 | lg: 'h-16 w-16', 6 | md: 'h-8 w-8', 7 | sm: 'h-4 w-4', 8 | xl: 'h-24 w-24', 9 | } 10 | 11 | const variants = { 12 | light: 'text-white', 13 | primary: 'text-blue-200', 14 | } 15 | 16 | export type SpinnerProps = { 17 | className?: string 18 | size?: keyof typeof sizes 19 | variant?: keyof typeof variants 20 | } 21 | 22 | const Spinner: React.FC = ({ 23 | className = '', 24 | size = 'md', 25 | variant = 'primary', 26 | }: SpinnerProps) => { 27 | return ( 28 | <> 29 | 41 | 49 | 54 | 55 | Loading 56 | 57 | ) 58 | } 59 | 60 | Spinner.displayName = 'Spinner' 61 | 62 | export default Spinner 63 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @plugin '@tailwindcss/forms'; 4 | 5 | @theme { 6 | --font-sans: Inter var, ui-sans-serif, system-ui, sans-serif, 7 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 8 | } 9 | 10 | /* 11 | The default border color has changed to `currentColor` in Tailwind CSS v4, 12 | so we've added these compatibility styles to make sure everything still 13 | looks the same as it did with Tailwind CSS v3. 14 | 15 | If we ever want to remove these styles, we need to add an explicit border 16 | color utility to any element that depends on these defaults. 17 | */ 18 | @layer base { 19 | *, 20 | ::after, 21 | ::before, 22 | ::backdrop, 23 | ::file-selector-button { 24 | border-color: var(--color-gray-200, currentColor); 25 | } 26 | } 27 | 28 | html, 29 | body { 30 | height: 100%; 31 | width: 100%; 32 | margin: 0; 33 | padding: 0; 34 | } 35 | 36 | #root { 37 | height: 100%; 38 | width: 100%; 39 | } 40 | 41 | body { 42 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 43 | 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 44 | 'Helvetica Neue', sans-serif; 45 | -webkit-font-smoothing: antialiased; 46 | -moz-osx-font-smoothing: grayscale; 47 | } 48 | 49 | code { 50 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 51 | monospace; 52 | } 53 | 54 | .react-logo { 55 | margin: auto; 56 | height: 30vmin; 57 | pointer-events: none; 58 | animation: react-logo-spin infinite 20s linear; 59 | } 60 | 61 | @keyframes react-logo-spin { 62 | from { 63 | transform: rotate(0deg); 64 | } 65 | to { 66 | transform: rotate(360deg); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/hooks/useUpdateEffect.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react' 2 | 3 | import useUpdateEffect from './useUpdateEffect' 4 | 5 | test('useUpdateEffect simulates componentDidUpdate', () => { 6 | const effect = vi.fn() 7 | const { rerender } = renderHook(() => useUpdateEffect(effect)) 8 | 9 | expect(effect).toHaveBeenCalledTimes(0) 10 | rerender() 11 | expect(effect).toHaveBeenCalledTimes(1) 12 | rerender() 13 | expect(effect).toHaveBeenCalledTimes(2) 14 | rerender() 15 | expect(effect).toHaveBeenCalledTimes(3) 16 | }) 17 | -------------------------------------------------------------------------------- /src/hooks/useUpdateEffect.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react' 2 | /** 3 | * Simulate componentDidUpdate() method of Class Component 4 | * https://reactjs.org/docs/react-component.html#componentdidupdate 5 | */ 6 | const useUpdateEffect = ( 7 | effect: AnyFunction, 8 | deps: any[] | undefined = undefined, 9 | ): void => { 10 | const mounted = useRef(false) 11 | useEffect(() => { 12 | if (!mounted.current) { 13 | // fire componentDidMount 14 | mounted.current = true 15 | } else { 16 | effect() 17 | } 18 | }, deps) 19 | } 20 | 21 | export default useUpdateEffect 22 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import './global.css' 4 | 5 | import App from './App' 6 | 7 | const root = ReactDOM.createRoot(document.getElementById('root')!) 8 | 9 | // Setup MSW mock server in both development and production 10 | // Certify MSW's Service Worker is available before starting React app 11 | import('../mocks/browser') 12 | .then(async ({ worker }) => { 13 | return worker.start() 14 | }) // Run when Service Worker is ready to intercept requests 15 | .then(() => { 16 | root.render() 17 | }) 18 | -------------------------------------------------------------------------------- /src/pages/Index/Counter.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useState } from 'react' 2 | 3 | interface Props {} 4 | 5 | const Counter: React.FC = memo(() => { 6 | const [count, setCount] = useState(0) 7 | 8 | return ( 9 | <> 10 | 16 | 17 | ) 18 | }) 19 | Counter.displayName = 'Counter' 20 | 21 | export default Counter 22 | -------------------------------------------------------------------------------- /src/pages/Index/DocList.module.css: -------------------------------------------------------------------------------- 1 | .documentList { 2 | margin-top: 20px; 3 | display: flex; 4 | gap: 10px; 5 | flex-wrap: wrap; 6 | } 7 | 8 | .button { 9 | flex: 1 0 calc(33.3333% - 10px); 10 | color: white; 11 | margin-top: 10px; 12 | font-size: 24px; 13 | padding: 14px 20px; 14 | border: 1px solid #fff; 15 | border-radius: 8px; 16 | cursor: pointer; 17 | text-align: center; 18 | text-decoration: none; 19 | display: inline-block; 20 | transition: background-color 0.5s; 21 | 22 | &:hover { 23 | background-color: rgba(255, 255, 255, 0.5); 24 | } 25 | } 26 | 27 | .link { 28 | position: absolute; 29 | top: 20px; 30 | left: 20px; 31 | font-size: 32px; 32 | color: #61dafb; 33 | } 34 | -------------------------------------------------------------------------------- /src/pages/Index/DocList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | 3 | import Spinner from '@/components/Spinner' 4 | 5 | import styles from './DocList.module.css' 6 | interface Props {} 7 | 8 | const DocList: React.FC = () => { 9 | const [docList, setDocList] = useState([]) 10 | 11 | useEffect(() => { 12 | fetch('/api/doclist') 13 | .then(async (res) => res.json()) 14 | .then((data) => setDocList(data)) 15 | }, []) 16 | 17 | return ( 18 | <> 19 |
30 | 31 | ) 32 | } 33 | DocList.displayName = 'DocList' 34 | 35 | export default DocList 36 | -------------------------------------------------------------------------------- /src/pages/Index/index.module.css: -------------------------------------------------------------------------------- 1 | .h1 { 2 | font-size: 44px; 3 | font-weight: 500; 4 | padding: 40px 0; 5 | } 6 | -------------------------------------------------------------------------------- /src/pages/Index/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, Suspense } from 'react' 2 | 3 | import Box from '../../components/Box' 4 | import Spinner from '../../components/Spinner' 5 | import logo from '../../logo.svg' 6 | 7 | import Counter from './Counter' 8 | import DocList from './DocList' 9 | import styles from './index.module.css' 10 | 11 | interface Props {} 12 | 13 | const Index: React.FC = memo(() => { 14 | return ( 15 | <> 16 | 17 |

I'm REACT_APP_TEXT from .env

18 | react-logo 19 |
20 | 21 | 22 | 23 | 24 | }> 25 | 26 | 27 | 28 | 29 | ) 30 | }) 31 | Index.displayName = 'Index' 32 | 33 | export default Index 34 | -------------------------------------------------------------------------------- /src/pages/Notfound/index.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | } 4 | 5 | .h1 { 6 | font-size: 3.75rem; 7 | line-height: 1; 8 | } 9 | -------------------------------------------------------------------------------- /src/pages/Notfound/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from 'react' 2 | 3 | import styles from './index.module.css' 4 | 5 | const Notfound: React.FC = memo(() => ( 6 |
7 |

404: Page Not Found

8 |
9 | )) 10 | Notfound.displayName = 'Notfound' 11 | 12 | export default Notfound 13 | -------------------------------------------------------------------------------- /src/router.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter } from 'react-router' 2 | 3 | import Layout from './components/Layout/Layout' 4 | import Index from './pages/Index' 5 | import Notfound from './pages/Notfound' 6 | 7 | const router = createBrowserRouter([ 8 | { 9 | path: '/', 10 | element: ( 11 | 12 | 13 | 14 | ), 15 | }, 16 | { 17 | path: '*', 18 | element: ( 19 | 20 | 21 | 22 | ), 23 | }, 24 | ]) 25 | 26 | export default router 27 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/vitest' 2 | 3 | import { server } from '../mocks/server' 4 | 5 | beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) 6 | 7 | afterEach(() => server.resetHandlers()) 8 | 9 | afterAll(() => server.close()) 10 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Bundler", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": ".", 19 | "paths": { 20 | "@/*": ["./src/*"] 21 | }, 22 | "types": ["vitest/globals", "@testing-library/jest-dom"] 23 | }, 24 | "include": [ 25 | "./src", 26 | "./@types", 27 | "./mocks", 28 | "scripts", 29 | "./**.js", 30 | "./**.ts", 31 | "eslint.config.mjs" 32 | ], 33 | "exclude": ["dist"] 34 | } 35 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | 3 | import react from '@vitejs/plugin-react-swc' 4 | import { defineConfig } from 'vite' 5 | import EnvironmentPlugin from 'vite-plugin-environment' 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | build: { 10 | sourcemap: true, 11 | }, 12 | plugins: [react(), EnvironmentPlugin(['REACT_APP_TEXT'])], 13 | publicDir: 'public', 14 | server: { 15 | host: true, 16 | port: 3000, 17 | }, 18 | resolve: { 19 | alias: { 20 | '@': path.resolve(__dirname, './src'), 21 | }, 22 | }, 23 | }) 24 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | 3 | import EnvironmentPlugin from 'vite-plugin-environment' 4 | import { defineConfig } from 'vitest/config' 5 | export default defineConfig({ 6 | plugins: [EnvironmentPlugin(['REACT_APP_TEXT']) as any], 7 | resolve: { 8 | alias: { 9 | '@': path.resolve(__dirname, './src'), 10 | }, 11 | }, 12 | test: { 13 | environment: 'jsdom', 14 | globals: true, 15 | setupFiles: ['./src/setupTests.ts'], 16 | include: ['src/**/*.{test,spec}.{ts,tsx}'], 17 | exclude: ['src/@types', 'node_modules'], 18 | }, 19 | }) 20 | --------------------------------------------------------------------------------
20 | {docList.length === 0 ? ( 21 | 22 | ) : ( 23 | docList.map((doc, i) => ( 24 | 25 | {doc.name} 26 | 27 | )) 28 | )} 29 |