├── .gitattributes ├── .github └── workflows │ └── validate.yml ├── .gitignore ├── .npmrc ├── .vscode ├── extensions.json └── settings.kcd.json ├── LICENSE.md ├── README.md ├── epicshop ├── .diffignore ├── .npmrc ├── Dockerfile ├── fix-watch.js ├── fix.js ├── fly.toml ├── in-browser-tests.spec.js ├── package-lock.json ├── package.json ├── playwright.config.js ├── post-set-playground.js ├── setup-custom.js ├── setup.js ├── tsconfig.json └── update-deps.sh ├── eslint.config.js ├── exercises ├── 01.fetching │ ├── 01.problem.throw │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 01.solution.throw │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── suspense.test.ts │ │ └── utils.tsx │ ├── 02.problem.errors │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 02.solution.errors │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── error.test.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 03.problem.status │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 03.solution.status │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── status.test.ts │ │ └── utils.tsx │ ├── 04.problem.util │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 04.solution.util │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── util.test.ts │ │ └── utils.tsx │ ├── 05.problem.use │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 05.solution.use │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── use.test.ts │ │ └── utils.tsx │ ├── FINISHED.mdx │ └── README.mdx ├── 02.dynamic │ ├── 01.problem.cache │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 01.solution.cache │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── cache.test.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 02.problem.transition │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 02.solution.transition │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── transition.test.ts │ │ └── utils.tsx │ ├── 03.problem.flash │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 03.solution.flash │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── spin-delay.test.ts │ │ └── utils.tsx │ ├── FINISHED.mdx │ └── README.mdx ├── 03.optimistic │ ├── 01.problem.optimistic │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 01.solution.optimistic │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── optimistic.test.ts │ │ └── utils.tsx │ ├── 02.problem.form-status │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 02.solution.form-status │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── status.test.ts │ │ └── utils.tsx │ ├── 03.problem.message │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 03.solution.message │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── message.test.ts │ │ └── utils.tsx │ ├── FINISHED.mdx │ └── README.mdx ├── 04.image │ ├── 01.problem.img │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 01.solution.img │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── suspense-img.test.ts │ │ └── utils.tsx │ ├── 02.problem.error │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 02.solution.error │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── img-error.test.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 03.problem.key │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 03.solution.key │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── suspense-key.test.ts │ │ └── utils.tsx │ ├── FINISHED.mdx │ └── README.mdx ├── 05.responsive │ ├── 01.problem.deferred │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 01.solution.deferred │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── responsive-search.test.ts │ │ └── utils.tsx │ ├── FINISHED.mdx │ └── README.mdx ├── 06.optimization │ ├── 01.problem.parallel │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 01.solution.parallel │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── parallel-loading.test.ts │ │ └── utils.tsx │ ├── 02.problem.cache │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── 02.solution.cache │ │ ├── README.mdx │ │ ├── api.server.ts │ │ ├── cache-control.test.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── utils.tsx │ ├── FINISHED.mdx │ └── README.mdx ├── FINISHED.mdx └── README.mdx ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── favicon.svg ├── images │ └── instructor.png ├── img │ ├── broken-ship.webp │ ├── fallback-ship.png │ └── ships │ │ ├── battleship.webp │ │ ├── bomber.webp │ │ ├── cargo-ship.webp │ │ ├── cruiser.webp │ │ ├── diplomatic-vessel.webp │ │ ├── dreadnought.webp │ │ ├── frigate.webp │ │ ├── galaxy-cruiser.webp │ │ ├── gunship.webp │ │ ├── infinity-drifter.webp │ │ ├── interceptor.webp │ │ ├── medical-ship.webp │ │ ├── mining-ship.webp │ │ ├── planet-hopper.webp │ │ ├── research-vessel.webp │ │ ├── scout-ship.webp │ │ ├── space-taxi.webp │ │ ├── star-destroyer.webp │ │ ├── star-hopper.webp │ │ ├── stealth-cruiser.webp │ │ └── transport-ship.webp ├── logo.svg └── og │ ├── background.png │ └── logo.svg ├── reset.d.ts ├── shared ├── reset.d.ts ├── ship-api-utils.server.ts ├── ships.json └── tsconfig.json └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - 'main' 11 | pull_request: 12 | branches: 13 | - 'main' 14 | jobs: 15 | setup: 16 | strategy: 17 | matrix: 18 | os: [ubuntu-latest, windows-latest, macos-latest] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - name: ⬇️ Checkout repo 22 | uses: actions/checkout@v4 23 | 24 | - name: ⎔ Setup node 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: 20 28 | 29 | - name: ▶️ Run setup script 30 | run: npm run setup 31 | 32 | - name: ʦ TypeScript 33 | run: npm run typecheck 34 | 35 | - name: ⬣ ESLint 36 | run: npm run lint 37 | 38 | # TODO: get this working again 39 | # - name: ⬇️ Install Playwright 40 | # run: npm --prefix epicshop run test:setup 41 | 42 | # - name: 🧪 In-browser tests 43 | # run: npm --prefix epicshop test 44 | 45 | deploy: 46 | name: 🚀 Deploy 47 | runs-on: ubuntu-latest 48 | # only deploy main branch on pushes 49 | if: 50 | ${{ github.ref == 'refs/heads/main' && github.event_name == 'push' && 51 | github.repository_owner == 'epicweb-dev' }} 52 | 53 | steps: 54 | - name: ⬇️ Checkout repo 55 | uses: actions/checkout@v4 56 | 57 | - name: 🎈 Setup Fly 58 | uses: superfly/flyctl-actions/setup-flyctl@1.5 59 | 60 | - name: 🚀 Deploy 61 | run: flyctl deploy --remote-only 62 | working-directory: ./epicshop 63 | env: 64 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | workspace/ 4 | **/.cache/ 5 | **/build/ 6 | **/dist/ 7 | **/public/build 8 | **/playwright-report 9 | data.db 10 | /playground 11 | **/tsconfig.tsbuildinfo 12 | 13 | /public/img/custom-ships 14 | 15 | # in a real app you'd want to not commit the .env 16 | # file as well, but since this is for a workshop 17 | # we're going to keep them around. 18 | # .env 19 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | registry=https://registry.npmjs.org/ 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "VisualStudioExptTeam.vscodeintellicode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.kcd.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.detectIndentation": true, 5 | "editor.fontFamily": "'Dank Mono', Menlo, Monaco, 'Courier New', monospace", 6 | "editor.fontLigatures": false, 7 | "editor.rulers": [80], 8 | "editor.snippetSuggestions": "top", 9 | "editor.wordBasedSuggestions": false, 10 | "editor.suggest.localityBonus": true, 11 | "editor.acceptSuggestionOnCommitCharacter": false, 12 | "[javascript]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode", 14 | "editor.suggestSelection": "recentlyUsed", 15 | "editor.suggest.showKeywords": false 16 | }, 17 | "editor.renderWhitespace": "boundary", 18 | "files.defaultLanguage": "{activeEditorLanguage}", 19 | "javascript.validate.enable": false, 20 | "search.exclude": { 21 | "**/node_modules": true, 22 | "**/bower_components": true, 23 | "**/coverage": true, 24 | "**/dist": true, 25 | "**/build": true, 26 | "**/.build": true, 27 | "**/.gh-pages": true 28 | }, 29 | "editor.codeActionsOnSave": { 30 | "source.fixAll.eslint": false 31 | }, 32 | "eslint.validate": [ 33 | "javascript", 34 | "javascriptreact", 35 | "typescript", 36 | "typescriptreact" 37 | ], 38 | "eslint.options": { 39 | "env": { 40 | "browser": true, 41 | "jest/globals": true, 42 | "es6": true 43 | }, 44 | "parserOptions": { 45 | "ecmaVersion": 2019, 46 | "sourceType": "module", 47 | "ecmaFeatures": { 48 | "jsx": true 49 | } 50 | }, 51 | "rules": { 52 | "no-debugger": "off" 53 | } 54 | }, 55 | "workbench.colorTheme": "Night Owl", 56 | "workbench.iconTheme": "material-icon-theme", 57 | "breadcrumbs.enabled": true, 58 | "grunt.autoDetect": "off", 59 | "gulp.autoDetect": "off", 60 | "npm.runSilent": true, 61 | "explorer.confirmDragAndDrop": false, 62 | "editor.formatOnPaste": false, 63 | "editor.cursorSmoothCaretAnimation": true, 64 | "editor.smoothScrolling": true, 65 | "php.suggest.basic": false 66 | } 67 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | This material is available for private, non-commercial use under the 2 | [GPL version 3](http://www.gnu.org/licenses/gpl-3.0-standalone.html). If you 3 | would like to use this material to conduct your own workshop, please contact us 4 | at team@epicweb.dev 5 | -------------------------------------------------------------------------------- /epicshop/.diffignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | -------------------------------------------------------------------------------- /epicshop/.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | registry=https://registry.npmjs.org/ 3 | -------------------------------------------------------------------------------- /epicshop/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-bookworm-slim as base 2 | 3 | RUN apt-get update && apt-get install -y git 4 | 5 | ENV EPICSHOP_CONTEXT_CWD="/myapp/workshop-content" 6 | ENV EPICSHOP_DEPLOYED="true" 7 | ENV EPICSHOP_DISABLE_WATCHER="true" 8 | ENV FLY="true" 9 | ENV PORT="8080" 10 | ENV NODE_ENV="production" 11 | 12 | WORKDIR /myapp 13 | 14 | ADD . . 15 | 16 | RUN npm install --omit=dev 17 | 18 | CMD rm -rf ${EPICSHOP_CONTEXT_CWD} && \ 19 | git clone https://github.com/epicweb-dev/react-suspense ${EPICSHOP_CONTEXT_CWD} && \ 20 | cd ${EPICSHOP_CONTEXT_CWD} && \ 21 | npm ci && \ 22 | npx epicshop start -------------------------------------------------------------------------------- /epicshop/fix-watch.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | import chokidar from 'chokidar' 4 | import { $ } from 'execa' 5 | 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 7 | const here = (...p) => path.join(__dirname, ...p) 8 | 9 | const workshopRoot = here('..') 10 | 11 | const watchPath = path.join(workshopRoot, './exercises/*') 12 | const watcher = chokidar.watch(watchPath, { 13 | ignored: /(^|[/\\])\../, // ignore dotfiles 14 | persistent: true, 15 | ignoreInitial: true, 16 | depth: 2, 17 | }) 18 | 19 | const debouncedRun = debounce(run, 200) 20 | 21 | // Add event listeners. 22 | watcher 23 | .on('addDir', (path) => { 24 | debouncedRun() 25 | }) 26 | .on('unlinkDir', (path) => { 27 | // Only act if path contains two slashes (excluding the leading `./`) 28 | debouncedRun() 29 | }) 30 | .on('error', (error) => console.log(`Watcher error: ${error}`)) 31 | 32 | /** 33 | * Simple debounce implementation 34 | */ 35 | function debounce(fn, delay) { 36 | let timer = null 37 | return (...args) => { 38 | if (timer) clearTimeout(timer) 39 | timer = setTimeout(() => { 40 | fn(...args) 41 | }, delay) 42 | } 43 | } 44 | 45 | let running = false 46 | 47 | async function run() { 48 | if (running) { 49 | console.log('still running...') 50 | return 51 | } 52 | running = true 53 | try { 54 | await $({ 55 | stdio: 'inherit', 56 | cwd: workshopRoot, 57 | })`node ./scripts/fix.js` 58 | } catch (error) { 59 | throw error 60 | } finally { 61 | running = false 62 | } 63 | } 64 | 65 | console.log(`watching ${watchPath}`) 66 | 67 | // doing this because the watcher doesn't seem to work and I don't have time 68 | // to figure out why 🙃 69 | console.log('Polling...') 70 | setInterval(() => { 71 | run() 72 | }, 1000) 73 | 74 | console.log('running fix to start...') 75 | run() 76 | -------------------------------------------------------------------------------- /epicshop/fly.toml: -------------------------------------------------------------------------------- 1 | app = "epicweb-dev-react-suspense" 2 | primary_region = "sjc" 3 | kill_signal = "SIGINT" 4 | kill_timeout = 5 5 | processes = [ ] 6 | swap_size_mb = 512 7 | 8 | [experimental] 9 | allowed_public_ports = [ ] 10 | auto_rollback = true 11 | 12 | [[services]] 13 | internal_port = 8080 14 | processes = [ "app" ] 15 | protocol = "tcp" 16 | script_checks = [ ] 17 | 18 | [services.concurrency] 19 | hard_limit = 100 20 | soft_limit = 80 21 | type = "connections" 22 | 23 | [[services.ports]] 24 | handlers = [ "http" ] 25 | port = 80 26 | force_https = true 27 | 28 | [[services.ports]] 29 | handlers = [ "tls", "http" ] 30 | port = 443 31 | 32 | [[services.tcp_checks]] 33 | grace_period = "1s" 34 | interval = "15s" 35 | restart_limit = 0 36 | timeout = "2s" 37 | 38 | [[services.http_checks]] 39 | interval = "10s" 40 | grace_period = "5s" 41 | method = "get" 42 | path = "/" 43 | protocol = "http" 44 | timeout = "2s" 45 | tls_skip_verify = false 46 | headers = { } -------------------------------------------------------------------------------- /epicshop/in-browser-tests.spec.js: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from 'path' 2 | import { fileURLToPath } from 'url' 3 | import { setupInBrowserTests } from '@epic-web/workshop-utils/playwright.server' 4 | 5 | const __dirname = dirname(fileURLToPath(import.meta.url)) 6 | process.env.EPICSHOP_CONTEXT_CWD = resolve(__dirname, '..') 7 | 8 | setupInBrowserTests() 9 | -------------------------------------------------------------------------------- /epicshop/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "test:setup": "playwright install chromium --with-deps", 5 | "test": "playwright test" 6 | }, 7 | "dependencies": { 8 | "@epic-web/config": "^1.16.3", 9 | "@epic-web/workshop-app": "^5.21.0", 10 | "@epic-web/workshop-utils": "^5.21.0", 11 | "execa": "^9.5.1" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /epicshop/playwright.config.js: -------------------------------------------------------------------------------- 1 | import os from 'os' 2 | import path from 'path' 3 | import { fileURLToPath } from 'url' 4 | import { defineConfig, devices } from '@playwright/test' 5 | 6 | const PORT = process.env.PORT || '3742' 7 | const tmpDir = path.join( 8 | os.tmpdir(), 9 | 'epicshop-playwright', 10 | path.basename(new URL('../', import.meta.url).pathname), 11 | ) 12 | 13 | // Get the directory name of the current module 14 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 15 | 16 | export default defineConfig({ 17 | workers: process.env.CI ? 1 : undefined, 18 | outputDir: path.join(tmpDir, 'playwright-test-output'), 19 | reporter: [ 20 | [ 21 | 'html', 22 | { open: 'never', outputFolder: path.join(tmpDir, 'playwright-report') }, 23 | ], 24 | ], 25 | use: { 26 | baseURL: `http://localhost:${PORT}/`, 27 | trace: 'retain-on-failure', 28 | // Some errors are expected, e.g. when a ship is not found 29 | ignoreHTTPSErrors: true, 30 | contextOptions: { 31 | ignoreHTTPErrors: true, 32 | }, 33 | // ignore 404 errors for resources 34 | bypassCSP: true, 35 | }, 36 | 37 | projects: [ 38 | { 39 | name: 'chromium', 40 | use: { ...devices['Desktop Chrome'] }, 41 | }, 42 | ], 43 | 44 | webServer: { 45 | command: 'npm start', 46 | cwd: path.resolve(__dirname, '..'), 47 | port: Number(PORT), 48 | reuseExistingServer: !process.env.CI, 49 | stdout: 'pipe', 50 | stderr: 'pipe', 51 | env: { PORT }, 52 | }, 53 | }) 54 | -------------------------------------------------------------------------------- /epicshop/post-set-playground.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | 4 | fs.writeFileSync( 5 | path.join(process.env.EPICSHOP_PLAYGROUND_DEST_DIR, 'tsconfig.json'), 6 | JSON.stringify({ extends: '../tsconfig' }, null, 2), 7 | ) 8 | -------------------------------------------------------------------------------- /epicshop/setup-custom.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { 3 | getApps, 4 | isProblemApp, 5 | setPlayground, 6 | } from '@epic-web/workshop-utils/apps.server' 7 | import fsExtra from 'fs-extra' 8 | 9 | const allApps = await getApps() 10 | const problemApps = allApps.filter(isProblemApp) 11 | 12 | if (!process.env.SKIP_PLAYGROUND) { 13 | const firstProblemApp = problemApps[0] 14 | if (firstProblemApp) { 15 | console.log('🛝 setting up the first problem app...') 16 | const playgroundPath = path.join(process.cwd(), 'playground') 17 | if (await fsExtra.exists(playgroundPath)) { 18 | console.log('🗑 deleting existing playground app') 19 | await fsExtra.remove(playgroundPath) 20 | } 21 | await setPlayground(firstProblemApp.fullPath).then( 22 | () => { 23 | console.log('✅ first problem app set up') 24 | }, 25 | (error) => { 26 | console.error(error) 27 | throw new Error('❌ first problem app setup failed') 28 | }, 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /epicshop/setup.js: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'child_process' 2 | 3 | const styles = { 4 | // got these from playing around with what I found from: 5 | // https://github.com/istanbuljs/istanbuljs/blob/0f328fd0896417ccb2085f4b7888dd8e167ba3fa/packages/istanbul-lib-report/lib/file-writer.js#L84-L96 6 | // they're the best I could find that works well for light or dark terminals 7 | success: { open: '\u001b[32;1m', close: '\u001b[0m' }, 8 | danger: { open: '\u001b[31;1m', close: '\u001b[0m' }, 9 | info: { open: '\u001b[36;1m', close: '\u001b[0m' }, 10 | subtitle: { open: '\u001b[2;1m', close: '\u001b[0m' }, 11 | } 12 | 13 | function color(modifier, string) { 14 | return styles[modifier].open + string + styles[modifier].close 15 | } 16 | 17 | console.log(color('info', '▶️ Starting workshop setup...')) 18 | 19 | const output = spawnSync('npm --version', { shell: true }) 20 | .stdout.toString() 21 | .trim() 22 | const outputParts = output.split('.') 23 | const major = Number(outputParts[0]) 24 | const minor = Number(outputParts[1]) 25 | if (major < 8 || (major === 8 && minor < 16)) { 26 | console.error( 27 | color( 28 | 'danger', 29 | '🚨 npm version is ' + 30 | output + 31 | ' which is out of date. Please install npm@8.16.0 or greater', 32 | ), 33 | ) 34 | throw new Error('npm version is out of date') 35 | } 36 | 37 | const command = 38 | 'npx --yes "https://gist.github.com/kentcdodds/bb452ffe53a5caa3600197e1d8005733" -q' 39 | console.log( 40 | color('subtitle', ' Running the following command: ' + command), 41 | ) 42 | 43 | const result = spawnSync(command, { stdio: 'inherit', shell: true }) 44 | 45 | if (result.status === 0) { 46 | console.log(color('success', '✅ Workshop setup complete...')) 47 | } else { 48 | process.exit(result.status) 49 | } 50 | 51 | /* 52 | eslint 53 | "no-undef": "off", 54 | "vars-on-top": "off", 55 | */ 56 | -------------------------------------------------------------------------------- /epicshop/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts", "**/*.tsx", "**/*.js"], 3 | "extends": ["@epic-web/config/typescript"] 4 | } 5 | -------------------------------------------------------------------------------- /epicshop/update-deps.sh: -------------------------------------------------------------------------------- 1 | npx npm-check-updates --dep prod,dev --upgrade --root 2 | cd epicshop && npx npm-check-updates --dep prod,dev --upgrade --root 3 | cd .. 4 | rm -rf node_modules package-lock.json ./epicshop/package-lock.json ./epicshop/node_modules ./exercises/**/node_modules 5 | npm install 6 | npm run setup 7 | npm run typecheck 8 | npm run lint --fix 9 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import defaultConfig from '@epic-web/config/eslint' 2 | 3 | /** @type {import("eslint").Linter.Config} */ 4 | export default [ 5 | ...defaultConfig, 6 | { 7 | rules: { 8 | // we leave unused vars around for the exercises 9 | 'no-unused-vars': 'off', 10 | '@typescript-eslint/no-unused-vars': 'off', 11 | }, 12 | }, 13 | ] 14 | -------------------------------------------------------------------------------- /exercises/01.fetching/01.problem.throw/README.mdx: -------------------------------------------------------------------------------- 1 | # Throwing Promises 2 | 3 | 4 | 5 | 👨‍💼 Right now the app is "working." This app displays information about space 6 | ships from a fictional sci-fi universe. The app is using a `fetch` request to 7 | get the data in the file. That fetch request 8 | gets routed (by the workshop app) through 9 | which retrieves the data from `./shared/ship-api-utils.server.ts`. 10 | 11 | The problem is that while the `fetch` request is ongoing, the user is just 12 | staring at a blank white screen. Now we could for sure improve the HTML document 13 | file a bit to have a loading state in HTML until the ship data shows up, but we 14 | want to be able to manage transitions like this as the user navigates around as 15 | well. Componentize all the things! 16 | 17 | So for this first step, you're going to need to remove the `await` on the 18 | `getShip` call and then if the `ship` data hasn't loaded yet, you'll throw the 19 | `shipPromise`. You can also wrap the `` in a `` 20 | boundary and render the `` component so we have a nicer loading 21 | state. 22 | 23 | Give that a whirl! 24 | -------------------------------------------------------------------------------- /exercises/01.fetching/01.problem.throw/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/01.fetching/01.problem.throw/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | 5 | * { 6 | box-sizing: border-box; 7 | } 8 | 9 | .app-wrapper { 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | justify-content: center; 14 | height: 100vh; 15 | } 16 | 17 | .ship-buttons { 18 | display: flex; 19 | justify-content: space-between; 20 | width: 300px; 21 | padding-bottom: 10px; 22 | } 23 | 24 | .ship-buttons button { 25 | border-radius: 2px; 26 | padding: 2px 4px; 27 | font-size: 0.75rem; 28 | background-color: white; 29 | color: black; 30 | &:not(.active) { 31 | box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.5); 32 | } 33 | &.active { 34 | box-shadow: inset 0 0 4px 0 rgba(0, 0, 0, 0.5); 35 | } 36 | } 37 | 38 | .app { 39 | display: flex; 40 | max-width: 1024px; 41 | border: 1px solid #000; 42 | border-start-end-radius: 0.5rem; 43 | border-start-start-radius: 0.5rem; 44 | border-end-start-radius: 50% 8%; 45 | border-end-end-radius: 50% 8%; 46 | overflow: hidden; 47 | } 48 | 49 | .search { 50 | width: 150px; 51 | max-height: 400px; 52 | overflow: hidden; 53 | display: flex; 54 | flex-direction: column; 55 | 56 | input { 57 | width: 100%; 58 | border: 0; 59 | border-bottom: 1px solid #000; 60 | padding: 8px; 61 | line-height: 1.5; 62 | border-top-left-radius: 0.5rem; 63 | } 64 | 65 | ul { 66 | flex: 1; 67 | list-style: none; 68 | padding: 4px; 69 | padding-bottom: 30px; 70 | margin: 0; 71 | display: flex; 72 | flex-direction: column; 73 | gap: 8px; 74 | overflow-y: auto; 75 | li { 76 | button { 77 | display: flex; 78 | align-items: center; 79 | gap: 4px; 80 | border: none; 81 | background-color: transparent; 82 | &:hover { 83 | text-decoration: underline; 84 | } 85 | img { 86 | width: 20px; 87 | height: 20px; 88 | object-fit: contain; 89 | border-radius: 50%; 90 | } 91 | } 92 | } 93 | } 94 | } 95 | 96 | .details { 97 | flex: 1; 98 | height: 400px; 99 | position: relative; 100 | overflow: hidden; 101 | } 102 | 103 | .ship-info { 104 | height: 100%; 105 | width: 300px; 106 | margin: auto; 107 | overflow: auto; 108 | background-color: #eee; 109 | border-radius: 4px; 110 | padding: 20px; 111 | position: relative; 112 | } 113 | 114 | .ship-info.ship-loading { 115 | opacity: 0.6; 116 | } 117 | 118 | .ship-info h2 { 119 | font-weight: bold; 120 | text-align: center; 121 | margin-top: 0.3em; 122 | } 123 | 124 | .ship-info img { 125 | width: 100%; 126 | height: 100%; 127 | aspect-ratio: 1; 128 | object-fit: contain; 129 | } 130 | 131 | .ship-info .ship-info__img-wrapper { 132 | margin-top: 20px; 133 | width: 100%; 134 | height: 200px; 135 | } 136 | 137 | .ship-info .ship-info__fetch-time { 138 | position: absolute; 139 | top: 6px; 140 | right: 10px; 141 | } 142 | 143 | .app-error { 144 | position: relative; 145 | background-image: url('/img/broken-ship.webp'); 146 | background-size: contain; 147 | background-repeat: no-repeat; 148 | background-position: center; 149 | width: 400px; 150 | height: 400px; 151 | p { 152 | position: absolute; 153 | top: 30%; 154 | left: 50%; 155 | transform: translate(-50%, -50%); 156 | background-color: white; 157 | padding: 6px 12px; 158 | border-radius: 1rem; 159 | font-size: 1.5rem; 160 | font-weight: bold; 161 | width: 300px; 162 | text-align: center; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /exercises/01.fetching/01.problem.throw/index.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | import { 4 | getImageUrlForShip, 5 | getShip, 6 | // 💰 you're gonna want this 7 | // type Ship 8 | } from './utils.tsx' 9 | 10 | const shipName = 'Dreadnought' 11 | 12 | function App() { 13 | return ( 14 |
15 |
16 |
17 | {/* 🐨 add a Suspense component here with the fallback set to */} 18 | 19 |
20 |
21 |
22 | ) 23 | } 24 | 25 | // 🐨 create a new ship variable that's a Ship 26 | // 💰 let ship: Ship 27 | // 🐨 rename this to shipPromise and remove the `await` 28 | // 🐨 add a .then on the shipPromise that assigns the ship to the resolved value 29 | const ship = await getShip(shipName) 30 | 31 | function ShipDetails() { 32 | // 🐨 if the ship hasn't loaded yet, throw the shipPromise 33 | 34 | return ( 35 |
36 |
37 | {ship.name} 41 |
42 |
43 |

44 | {ship.name} 45 | 46 | {ship.topSpeed} lyh 47 | 48 |

49 |
50 |
51 | {ship.weapons.length ? ( 52 |
    53 | {ship.weapons.map((weapon) => ( 54 |
  • 55 | :{' '} 56 | 57 | {weapon.damage} ({weapon.type}) 58 | 59 |
  • 60 | ))} 61 |
62 | ) : ( 63 |

NOTE: This ship is not equipped with any weapons.

64 | )} 65 |
66 | {ship.fetchedAt} 67 |
68 | ) 69 | } 70 | 71 | function ShipFallback() { 72 | return ( 73 |
74 |
75 | {shipName} 76 |
77 |
78 |

79 | {shipName} 80 | 81 | XX lyh 82 | 83 |

84 |
85 |
86 |
    87 | {Array.from({ length: 3 }).map((_, i) => ( 88 |
  • 89 | :{' '} 90 | 91 | XX (loading) 92 | 93 |
  • 94 | ))} 95 |
96 |
97 |
98 | ) 99 | } 100 | 101 | const rootEl = document.createElement('div') 102 | document.body.append(rootEl) 103 | ReactDOM.createRoot(rootEl).render() 104 | -------------------------------------------------------------------------------- /exercises/01.fetching/01.problem.throw/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function getShip(name: string, delay?: number) { 6 | const searchParams = new URLSearchParams({ name }) 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 9 | if (!response.ok) { 10 | return Promise.reject(new Error(await response.text())) 11 | } 12 | const ship = await response.json() 13 | return ship as Ship 14 | } 15 | 16 | export function getImageUrlForShip( 17 | shipName: string, 18 | { size }: { size: number }, 19 | ) { 20 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 21 | } 22 | -------------------------------------------------------------------------------- /exercises/01.fetching/01.solution.throw/README.mdx: -------------------------------------------------------------------------------- 1 | # Throwing Promises 2 | 3 | 4 | 5 | 👨‍💼 Great job! You've successfully thrown the promise, thereby suspending the 6 | `ShipDetails` component so React can render the suspense fallback while we wait 7 | for the ship to load. We're on our way! 8 | -------------------------------------------------------------------------------- /exercises/01.fetching/01.solution.throw/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/01.fetching/01.solution.throw/index.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | import { getImageUrlForShip, getShip, type Ship } from './utils.tsx' 4 | 5 | const shipName = 'Dreadnought' 6 | 7 | function App() { 8 | return ( 9 |
10 |
11 |
12 | }> 13 | 14 | 15 |
16 |
17 |
18 | ) 19 | } 20 | 21 | let ship: Ship 22 | const shipPromise = getShip(shipName, 1000).then((result) => (ship = result)) 23 | 24 | function ShipDetails() { 25 | if (!ship) throw shipPromise 26 | 27 | return ( 28 |
29 |
30 | {ship.name} 34 |
35 |
36 |

37 | {ship.name} 38 | 39 | {ship.topSpeed} lyh 40 | 41 |

42 |
43 |
44 | {ship.weapons.length ? ( 45 |
    46 | {ship.weapons.map((weapon) => ( 47 |
  • 48 | :{' '} 49 | 50 | {weapon.damage} ({weapon.type}) 51 | 52 |
  • 53 | ))} 54 |
55 | ) : ( 56 |

NOTE: This ship is not equipped with any weapons.

57 | )} 58 |
59 | {ship.fetchedAt} 60 |
61 | ) 62 | } 63 | 64 | function ShipFallback() { 65 | return ( 66 |
67 |
68 | {shipName} 69 |
70 |
71 |

72 | {shipName} 73 | 74 | XX lyh 75 | 76 |

77 |
78 |
79 |
    80 | {Array.from({ length: 3 }).map((_, i) => ( 81 |
  • 82 | :{' '} 83 | 84 | XX (loading) 85 | 86 |
  • 87 | ))} 88 |
89 |
90 |
91 | ) 92 | } 93 | 94 | const rootEl = document.createElement('div') 95 | document.body.append(rootEl) 96 | ReactDOM.createRoot(rootEl).render() 97 | -------------------------------------------------------------------------------- /exercises/01.fetching/01.solution.throw/suspense.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, waitForElementToBeRemoved } = dtl 3 | 4 | import './index.tsx' 5 | 6 | const fallbackImage = await testStep( 7 | 'Suspense boundary renders ShipFallback', 8 | async () => { 9 | const image = await screen.findByAltText('Dreadnought') 10 | if (!(image instanceof HTMLImageElement)) { 11 | throw new Error('Fallback image not found') 12 | } 13 | expect( 14 | image.src, 15 | '🚨 make sure to render the suspense boundary with the fallback', 16 | ).toContain('/img/fallback-ship.png') 17 | return image 18 | }, 19 | ) 20 | 21 | await testStep('ShipFallback contains loading placeholders', async () => { 22 | const [loadingItem] = await screen.findAllByText('loading') 23 | expect(loadingItem).toBeInTheDocument() 24 | }) 25 | 26 | await testStep('Actual content loads and replaces fallback', async () => { 27 | await waitForElementToBeRemoved(() => screen.queryAllByText('loading'), { 28 | timeout: 5000, 29 | }) 30 | }) 31 | 32 | await testStep('Actual ship details are rendered', async () => { 33 | const image = await screen.findByAltText('Dreadnought') 34 | if (!(image instanceof HTMLImageElement)) { 35 | throw new Error('Ship image not found') 36 | } 37 | expect(image.src).not.toContain('/img/fallback-ship.png') 38 | expect(image).not.toBe(fallbackImage) 39 | }) 40 | 41 | await testStep('Weapon items are displayed', async () => { 42 | const weaponItems = await screen.findAllByRole('listitem') 43 | expect(weaponItems.length).toBeGreaterThan(0) 44 | }) 45 | -------------------------------------------------------------------------------- /exercises/01.fetching/01.solution.throw/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function getShip(name: string, delay?: number) { 6 | const searchParams = new URLSearchParams({ name }) 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 9 | if (!response.ok) { 10 | return Promise.reject(new Error(await response.text())) 11 | } 12 | const ship = await response.json() 13 | return ship as Ship 14 | } 15 | 16 | export function getImageUrlForShip( 17 | shipName: string, 18 | { size }: { size: number }, 19 | ) { 20 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 21 | } 22 | -------------------------------------------------------------------------------- /exercises/01.fetching/02.problem.errors/README.mdx: -------------------------------------------------------------------------------- 1 | # Error Handling 2 | 3 | 4 | 5 | 👨‍💼 If the user has a bad network connection or something we want to handle that 6 | error case gracefully. Please wrap the `ShipDetails` in an `ErrorBoundary` from 7 | `react-error-boundary`. 8 | 9 | You can test this out by changing the `shipName` to a ship that doesn't exist. 10 | We also have a good fallback for you to use that 🧝‍♂️ Kellie made called 11 | `ShipError`. Good luck! 12 | -------------------------------------------------------------------------------- /exercises/01.fetching/02.problem.errors/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/01.fetching/02.problem.errors/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function getShip(name: string, delay?: number) { 6 | const searchParams = new URLSearchParams({ name }) 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 9 | if (!response.ok) { 10 | return Promise.reject(new Error(await response.text())) 11 | } 12 | const ship = await response.json() 13 | return ship as Ship 14 | } 15 | 16 | export function getImageUrlForShip( 17 | shipName: string, 18 | { size }: { size: number }, 19 | ) { 20 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 21 | } 22 | -------------------------------------------------------------------------------- /exercises/01.fetching/02.solution.errors/README.mdx: -------------------------------------------------------------------------------- 1 | # Error Handling 2 | 3 | 4 | 5 | 👨‍💼 Great work! Now we have a nice way to declaratively and gracefully handle 6 | errors when our data loading has issues. 7 | -------------------------------------------------------------------------------- /exercises/01.fetching/02.solution.errors/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/01.fetching/02.solution.errors/error.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep( 7 | 'Error boundary renders ShipError when ship is not found', 8 | async () => { 9 | // Check for the error message 10 | const errorMessage = await screen.findByText('There was an error') 11 | expect(errorMessage).toBeInTheDocument() 12 | 13 | // Check for the specific error message including the ship name 14 | const specificErrorMessage = await screen.findByText( 15 | 'There was an error loading "Dreadyacht"', 16 | ) 17 | expect(specificErrorMessage).toBeInTheDocument() 18 | 19 | // Check for the broken ship image 20 | const brokenShipImage = await screen.findByAltText('broken ship') 21 | if (!(brokenShipImage instanceof HTMLImageElement)) { 22 | throw new Error('Broken ship image not found') 23 | } 24 | expect(brokenShipImage.src).toContain('/img/broken-ship.webp') 25 | }, 26 | ) 27 | -------------------------------------------------------------------------------- /exercises/01.fetching/02.solution.errors/index.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | import { ErrorBoundary } from 'react-error-boundary' 4 | import { getImageUrlForShip, getShip, type Ship } from './utils.tsx' 5 | 6 | const shipName = 'Dreadyacht' 7 | 8 | function App() { 9 | return ( 10 |
11 |
12 |
13 | }> 14 | }> 15 | 16 | 17 | 18 |
19 |
20 |
21 | ) 22 | } 23 | 24 | let ship: Ship 25 | let error: unknown 26 | const shipPromise = getShip(shipName).then( 27 | (result) => (ship = result), 28 | (err) => (error = err), 29 | ) 30 | 31 | function ShipDetails() { 32 | if (error) throw error 33 | if (!ship) throw shipPromise 34 | 35 | return ( 36 |
37 |
38 | {ship.name} 42 |
43 |
44 |

45 | {ship.name} 46 | 47 | {ship.topSpeed} lyh 48 | 49 |

50 |
51 |
52 | {ship.weapons.length ? ( 53 |
    54 | {ship.weapons.map((weapon) => ( 55 |
  • 56 | :{' '} 57 | 58 | {weapon.damage} ({weapon.type}) 59 | 60 |
  • 61 | ))} 62 |
63 | ) : ( 64 |

NOTE: This ship is not equipped with any weapons.

65 | )} 66 |
67 | {ship.fetchedAt} 68 |
69 | ) 70 | } 71 | 72 | function ShipFallback() { 73 | return ( 74 |
75 |
76 | {shipName} 77 |
78 |
79 |

80 | {shipName} 81 | 82 | XX lyh 83 | 84 |

85 |
86 |
87 |
    88 | {Array.from({ length: 3 }).map((_, i) => ( 89 |
  • 90 | :{' '} 91 | 92 | XX (loading) 93 | 94 |
  • 95 | ))} 96 |
97 |
98 |
99 | ) 100 | } 101 | 102 | function ShipError() { 103 | return ( 104 |
105 |
106 | broken ship 107 |
108 |
109 |

There was an error

110 |
111 |
There was an error loading "{shipName}"
112 |
113 | ) 114 | } 115 | 116 | const rootEl = document.createElement('div') 117 | document.body.append(rootEl) 118 | ReactDOM.createRoot(rootEl).render() 119 | -------------------------------------------------------------------------------- /exercises/01.fetching/02.solution.errors/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function getShip(name: string, delay?: number) { 6 | const searchParams = new URLSearchParams({ name }) 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 9 | if (!response.ok) { 10 | return Promise.reject(new Error(await response.text())) 11 | } 12 | const ship = await response.json() 13 | return ship as Ship 14 | } 15 | 16 | export function getImageUrlForShip( 17 | shipName: string, 18 | { size }: { size: number }, 19 | ) { 20 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 21 | } 22 | -------------------------------------------------------------------------------- /exercises/01.fetching/03.problem.status/README.mdx: -------------------------------------------------------------------------------- 1 | # Formal Status 2 | 3 | 4 | 5 | 👨‍💼 Let's clean up things a little bit with what we've built so far by making the 6 | status of our promise a little more formal. 7 | 8 | What we have now is: 9 | 10 | - if there's an error, throw it 11 | - if there's no ship, throw the promise 12 | - render the ship 13 | 14 | But that's not exactly clear. 15 | 16 | So instead, let's add a `status` variable that can be 17 | `'pending' | 'fulfilled' | 'rejected'` (start it out with `'pending'`). 18 | 19 | 📜 To learn more about why this is important, read 20 | [Make Impossible States Impossible](https://kentcdodds.com/blog/make-impossible-states-impossible) 21 | and 22 | [Stop using isLoading booleans](https://kentcdodds.com/blog/stop-using-isloading-booleans) 23 | -------------------------------------------------------------------------------- /exercises/01.fetching/03.problem.status/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/01.fetching/03.problem.status/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function getShip(name: string, delay?: number) { 6 | const searchParams = new URLSearchParams({ name }) 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 9 | if (!response.ok) { 10 | return Promise.reject(new Error(await response.text())) 11 | } 12 | const ship = await response.json() 13 | return ship as Ship 14 | } 15 | 16 | export function getImageUrlForShip( 17 | shipName: string, 18 | { size }: { size: number }, 19 | ) { 20 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 21 | } 22 | -------------------------------------------------------------------------------- /exercises/01.fetching/03.solution.status/README.mdx: -------------------------------------------------------------------------------- 1 | # Formal Status 2 | 3 | 4 | 5 | 👨‍💼 Great. That feels much better defined than what we had before. That prepares 6 | us well for the next step. 7 | -------------------------------------------------------------------------------- /exercises/01.fetching/03.solution.status/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/01.fetching/03.solution.status/index.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | import { ErrorBoundary } from 'react-error-boundary' 4 | import { getImageUrlForShip, getShip, type Ship } from './utils.tsx' 5 | 6 | const shipName = 'Dreadnought' 7 | // 🚨 If you want to to test out the error state, change this to 'Dreadyacht' 8 | // const shipName = 'Dreadyacht' 9 | 10 | function App() { 11 | return ( 12 |
13 |
14 |
15 | }> 16 | }> 17 | 18 | 19 | 20 |
21 |
22 |
23 | ) 24 | } 25 | 26 | let ship: Ship 27 | let error: unknown 28 | let status: 'pending' | 'rejected' | 'fulfilled' = 'pending' 29 | const shipPromise = getShip(shipName).then( 30 | (result) => { 31 | ship = result 32 | status = 'fulfilled' 33 | }, 34 | (err) => { 35 | error = err 36 | status = 'rejected' 37 | }, 38 | ) 39 | 40 | function ShipDetails() { 41 | if (status === 'rejected') throw error 42 | if (status === 'pending') throw shipPromise 43 | 44 | return ( 45 |
46 |
47 | {ship.name} 51 |
52 |
53 |

54 | {ship.name} 55 | 56 | {ship.topSpeed} lyh 57 | 58 |

59 |
60 |
61 | {ship.weapons.length ? ( 62 |
    63 | {ship.weapons.map((weapon) => ( 64 |
  • 65 | :{' '} 66 | 67 | {weapon.damage} ({weapon.type}) 68 | 69 |
  • 70 | ))} 71 |
72 | ) : ( 73 |

NOTE: This ship is not equipped with any weapons.

74 | )} 75 |
76 | {ship.fetchedAt} 77 |
78 | ) 79 | } 80 | 81 | function ShipFallback() { 82 | return ( 83 |
84 |
85 | {shipName} 86 |
87 |
88 |

89 | {shipName} 90 | 91 | XX lyh 92 | 93 |

94 |
95 |
96 |
    97 | {Array.from({ length: 3 }).map((_, i) => ( 98 |
  • 99 | :{' '} 100 | 101 | XX (loading) 102 | 103 |
  • 104 | ))} 105 |
106 |
107 |
108 | ) 109 | } 110 | 111 | function ShipError() { 112 | return ( 113 |
114 |
115 | broken ship 116 |
117 |
118 |

There was an error

119 |
120 |
There was an error loading "{shipName}"
121 |
122 | ) 123 | } 124 | 125 | const rootEl = document.createElement('div') 126 | document.body.append(rootEl) 127 | ReactDOM.createRoot(rootEl).render() 128 | -------------------------------------------------------------------------------- /exercises/01.fetching/03.solution.status/status.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, waitForElementToBeRemoved } = dtl 3 | 4 | import './index.tsx' 5 | 6 | const shipName = await Promise.race([ 7 | screen.findByText('Dreadnought').then(() => 'Dreadnought'), 8 | screen.findByText('Dreadyacht').then(() => 'Dreadyacht'), 9 | ]) 10 | 11 | if (shipName === 'Dreadyacht') { 12 | await testStep( 13 | 'Error boundary renders ShipError when ship is not found', 14 | async () => { 15 | // Check for the error message 16 | const errorMessage = await screen.findByText('There was an error') 17 | expect(errorMessage).toBeInTheDocument() 18 | 19 | // Check for the specific error message including the ship name 20 | const specificErrorMessage = await screen.findByText( 21 | 'There was an error loading "Dreadyacht"', 22 | ) 23 | expect(specificErrorMessage).toBeInTheDocument() 24 | 25 | // Check for the broken ship image 26 | const brokenShipImage = await screen.findByAltText('broken ship') 27 | if (!(brokenShipImage instanceof HTMLImageElement)) { 28 | throw new Error('Broken ship image not found') 29 | } 30 | expect(brokenShipImage.src).toContain('/img/broken-ship.webp') 31 | }, 32 | ) 33 | } else { 34 | const fallbackImage = await testStep( 35 | 'Suspense boundary renders ShipFallback', 36 | async () => { 37 | const image = await screen.findByAltText('Dreadnought') 38 | if (!(image instanceof HTMLImageElement)) { 39 | throw new Error('Fallback image not found') 40 | } 41 | expect( 42 | image.src, 43 | '🚨 make sure to render the suspense boundary with the fallback', 44 | ).toContain('/img/fallback-ship.png') 45 | return image 46 | }, 47 | ) 48 | 49 | await testStep('ShipFallback contains loading placeholders', async () => { 50 | const [loadingItem] = await screen.findAllByText('loading') 51 | expect(loadingItem).toBeInTheDocument() 52 | }) 53 | 54 | await testStep('Actual content loads and replaces fallback', async () => { 55 | await waitForElementToBeRemoved(() => screen.queryAllByText('loading'), { 56 | timeout: 5000, 57 | }) 58 | }) 59 | 60 | await testStep('Actual ship details are rendered', async () => { 61 | const image = await screen.findByAltText('Dreadnought') 62 | if (!(image instanceof HTMLImageElement)) { 63 | throw new Error('Ship image not found') 64 | } 65 | expect(image.src).not.toContain('/img/fallback-ship.png') 66 | expect(image).not.toBe(fallbackImage) 67 | }) 68 | 69 | await testStep('Weapon items are displayed', async () => { 70 | const weaponItems = await screen.findAllByRole('listitem') 71 | expect(weaponItems.length).toBeGreaterThan(0) 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /exercises/01.fetching/03.solution.status/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function getShip(name: string, delay?: number) { 6 | const searchParams = new URLSearchParams({ name }) 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 9 | if (!response.ok) { 10 | return Promise.reject(new Error(await response.text())) 11 | } 12 | const ship = await response.json() 13 | return ship as Ship 14 | } 15 | 16 | export function getImageUrlForShip( 17 | shipName: string, 18 | { size }: { size: number }, 19 | ) { 20 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 21 | } 22 | -------------------------------------------------------------------------------- /exercises/01.fetching/04.problem.util/README.mdx: -------------------------------------------------------------------------------- 1 | # Utility 2 | 3 | 4 | 5 | 👨‍💼 With what you've built so far, we want you to make a reusable utility for 6 | this use case. We want you to call it `use` and it should take a promise and 7 | return the `Value` from the promise. 8 | 9 | The only way we can do this is by tracking some values which we'll monkey-patch 10 | onto the `promise` itself. So Kellie's added a special type for you to use to 11 | make TypeScript happier with the hackery we plan to perform for this simplified 12 | version of `use`. 13 | 14 | 🧝‍♂️ Here's a good start for you: 15 | 16 | ```tsx 17 | type UsePromise = Promise & { 18 | status: 'pending' | 'fulfilled' | 'rejected' 19 | value: Value 20 | reason: unknown 21 | } 22 | 23 | function use(promise: Promise): Value { 24 | const usePromise = promise as UsePromise 25 | // throw stuff, .then stuff, and return Value! 26 | } 27 | ``` 28 | 29 | That should get you a good start. When you're done, you should be able to remove 30 | a bunch of our code and replace it with a `use` call. Good luck! 31 | -------------------------------------------------------------------------------- /exercises/01.fetching/04.problem.util/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/01.fetching/04.problem.util/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function getShip(name: string, delay?: number) { 6 | const searchParams = new URLSearchParams({ name }) 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 9 | if (!response.ok) { 10 | return Promise.reject(new Error(await response.text())) 11 | } 12 | const ship = await response.json() 13 | return ship as Ship 14 | } 15 | 16 | export function getImageUrlForShip( 17 | shipName: string, 18 | { size }: { size: number }, 19 | ) { 20 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 21 | } 22 | -------------------------------------------------------------------------------- /exercises/01.fetching/04.solution.util/README.mdx: -------------------------------------------------------------------------------- 1 | # Utility 2 | 3 | 4 | 5 | 👨‍💼 Great job on that. Isn't it nice that React actually has this built-in? Let's 6 | **use** that next 😉 7 | -------------------------------------------------------------------------------- /exercises/01.fetching/04.solution.util/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/01.fetching/04.solution.util/util.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, waitForElementToBeRemoved } = dtl 3 | 4 | import './index.tsx' 5 | 6 | const shipName = await Promise.race([ 7 | screen.findByText('Dreadnought').then(() => 'Dreadnought'), 8 | screen.findByText('Dreadyacht').then(() => 'Dreadyacht'), 9 | ]) 10 | 11 | if (shipName === 'Dreadyacht') { 12 | await testStep( 13 | 'Error boundary renders ShipError when ship is not found', 14 | async () => { 15 | // Check for the error message 16 | const errorMessage = await screen.findByText('There was an error') 17 | expect(errorMessage).toBeInTheDocument() 18 | 19 | // Check for the specific error message including the ship name 20 | const specificErrorMessage = await screen.findByText( 21 | 'There was an error loading "Dreadyacht"', 22 | ) 23 | expect(specificErrorMessage).toBeInTheDocument() 24 | 25 | // Check for the broken ship image 26 | const brokenShipImage = await screen.findByAltText('broken ship') 27 | if (!(brokenShipImage instanceof HTMLImageElement)) { 28 | throw new Error('Broken ship image not found') 29 | } 30 | expect(brokenShipImage.src).toContain('/img/broken-ship.webp') 31 | }, 32 | ) 33 | } else { 34 | const fallbackImage = await testStep( 35 | 'Suspense boundary renders ShipFallback', 36 | async () => { 37 | const image = await screen.findByAltText('Dreadnought') 38 | if (!(image instanceof HTMLImageElement)) { 39 | throw new Error('Fallback image not found') 40 | } 41 | expect( 42 | image.src, 43 | '🚨 make sure to render the suspense boundary with the fallback', 44 | ).toContain('/img/fallback-ship.png') 45 | return image 46 | }, 47 | ) 48 | 49 | await testStep('ShipFallback contains loading placeholders', async () => { 50 | const [loadingItem] = await screen.findAllByText('loading') 51 | expect(loadingItem).toBeInTheDocument() 52 | }) 53 | 54 | await testStep('Actual content loads and replaces fallback', async () => { 55 | await waitForElementToBeRemoved(() => screen.queryAllByText('loading'), { 56 | timeout: 5000, 57 | }) 58 | }) 59 | 60 | await testStep('Actual ship details are rendered', async () => { 61 | const image = await screen.findByAltText('Dreadnought') 62 | if (!(image instanceof HTMLImageElement)) { 63 | throw new Error('Ship image not found') 64 | } 65 | expect(image.src).not.toContain('/img/fallback-ship.png') 66 | expect(image).not.toBe(fallbackImage) 67 | }) 68 | 69 | await testStep('Weapon items are displayed', async () => { 70 | const weaponItems = await screen.findAllByRole('listitem') 71 | expect(weaponItems.length).toBeGreaterThan(0) 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /exercises/01.fetching/04.solution.util/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function getShip(name: string, delay?: number) { 6 | const searchParams = new URLSearchParams({ name }) 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 9 | if (!response.ok) { 10 | return Promise.reject(new Error(await response.text())) 11 | } 12 | const ship = await response.json() 13 | return ship as Ship 14 | } 15 | 16 | export function getImageUrlForShip( 17 | shipName: string, 18 | { size }: { size: number }, 19 | ) { 20 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 21 | } 22 | -------------------------------------------------------------------------------- /exercises/01.fetching/05.problem.use/README.mdx: -------------------------------------------------------------------------------- 1 | # use React 2 | 3 | 4 | 5 | 👨‍💼 Ok, let's use React's built-in `use` function instead of our utility. Delete 6 | all the code in there and replace it with 7 | [`use`](https://react.dev/reference/react/use) from React. 8 | -------------------------------------------------------------------------------- /exercises/01.fetching/05.problem.use/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/01.fetching/05.problem.use/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function getShip(name: string, delay?: number) { 6 | const searchParams = new URLSearchParams({ name }) 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 9 | if (!response.ok) { 10 | return Promise.reject(new Error(await response.text())) 11 | } 12 | const ship = await response.json() 13 | return ship as Ship 14 | } 15 | 16 | export function getImageUrlForShip( 17 | shipName: string, 18 | { size }: { size: number }, 19 | ) { 20 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 21 | } 22 | -------------------------------------------------------------------------------- /exercises/01.fetching/05.solution.use/README.mdx: -------------------------------------------------------------------------------- 1 | # use React 2 | 3 | 4 | 5 | 👨‍💼 Great job! It always feels nice to delete code. 6 | -------------------------------------------------------------------------------- /exercises/01.fetching/05.solution.use/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/01.fetching/05.solution.use/index.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, use } from 'react' 2 | import * as ReactDOM from 'react-dom/client' 3 | import { ErrorBoundary } from 'react-error-boundary' 4 | import { getImageUrlForShip, getShip } from './utils.tsx' 5 | 6 | const shipName = 'Dreadnought' 7 | // 🚨 If you want to to test out the error state, change this to 'Dreadyacht' 8 | // const shipName = 'Dreadyacht' 9 | 10 | function App() { 11 | return ( 12 |
13 |
14 |
15 | }> 16 | }> 17 | 18 | 19 | 20 |
21 |
22 |
23 | ) 24 | } 25 | 26 | const shipPromise = getShip(shipName) 27 | 28 | function ShipDetails() { 29 | const ship = use(shipPromise) 30 | return ( 31 |
32 |
33 | {ship.name} 37 |
38 |
39 |

40 | {ship.name} 41 | 42 | {ship.topSpeed} lyh 43 | 44 |

45 |
46 |
47 | {ship.weapons.length ? ( 48 |
    49 | {ship.weapons.map((weapon) => ( 50 |
  • 51 | :{' '} 52 | 53 | {weapon.damage} ({weapon.type}) 54 | 55 |
  • 56 | ))} 57 |
58 | ) : ( 59 |

NOTE: This ship is not equipped with any weapons.

60 | )} 61 |
62 | {ship.fetchedAt} 63 |
64 | ) 65 | } 66 | 67 | function ShipFallback() { 68 | return ( 69 |
70 |
71 | {shipName} 72 |
73 |
74 |

75 | {shipName} 76 | 77 | XX lyh 78 | 79 |

80 |
81 |
82 |
    83 | {Array.from({ length: 3 }).map((_, i) => ( 84 |
  • 85 | :{' '} 86 | 87 | XX (loading) 88 | 89 |
  • 90 | ))} 91 |
92 |
93 |
94 | ) 95 | } 96 | 97 | function ShipError() { 98 | return ( 99 |
100 |
101 | broken ship 102 |
103 |
104 |

There was an error

105 |
106 |
There was an error loading "{shipName}"
107 |
108 | ) 109 | } 110 | 111 | const rootEl = document.createElement('div') 112 | document.body.append(rootEl) 113 | ReactDOM.createRoot(rootEl).render() 114 | -------------------------------------------------------------------------------- /exercises/01.fetching/05.solution.use/use.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, waitForElementToBeRemoved } = dtl 3 | 4 | import './index.tsx' 5 | 6 | const shipName = await Promise.race([ 7 | screen.findByText('Dreadnought').then(() => 'Dreadnought'), 8 | screen.findByText('Dreadyacht').then(() => 'Dreadyacht'), 9 | ]) 10 | 11 | if (shipName === 'Dreadyacht') { 12 | await testStep( 13 | 'Error boundary renders ShipError when ship is not found', 14 | async () => { 15 | // Check for the error message 16 | const errorMessage = await screen.findByText('There was an error') 17 | expect(errorMessage).toBeInTheDocument() 18 | 19 | // Check for the specific error message including the ship name 20 | const specificErrorMessage = await screen.findByText( 21 | 'There was an error loading "Dreadyacht"', 22 | ) 23 | expect(specificErrorMessage).toBeInTheDocument() 24 | 25 | // Check for the broken ship image 26 | const brokenShipImage = await screen.findByAltText('broken ship') 27 | if (!(brokenShipImage instanceof HTMLImageElement)) { 28 | throw new Error('Broken ship image not found') 29 | } 30 | expect(brokenShipImage.src).toContain('/img/broken-ship.webp') 31 | }, 32 | ) 33 | } else { 34 | const fallbackImage = await testStep( 35 | 'Suspense boundary renders ShipFallback', 36 | async () => { 37 | const image = await screen.findByAltText('Dreadnought') 38 | if (!(image instanceof HTMLImageElement)) { 39 | throw new Error('Fallback image not found') 40 | } 41 | expect( 42 | image.src, 43 | '🚨 make sure to render the suspense boundary with the fallback', 44 | ).toContain('/img/fallback-ship.png') 45 | return image 46 | }, 47 | ) 48 | 49 | await testStep('ShipFallback contains loading placeholders', async () => { 50 | const [loadingItem] = await screen.findAllByText('loading') 51 | expect(loadingItem).toBeInTheDocument() 52 | }) 53 | 54 | await testStep('Actual content loads and replaces fallback', async () => { 55 | await waitForElementToBeRemoved(() => screen.queryAllByText('loading'), { 56 | timeout: 5000, 57 | }) 58 | }) 59 | 60 | await testStep('Actual ship details are rendered', async () => { 61 | const image = await screen.findByAltText('Dreadnought') 62 | if (!(image instanceof HTMLImageElement)) { 63 | throw new Error('Ship image not found') 64 | } 65 | expect(image.src).not.toContain('/img/fallback-ship.png') 66 | expect(image).not.toBe(fallbackImage) 67 | }) 68 | 69 | await testStep('Weapon items are displayed', async () => { 70 | const weaponItems = await screen.findAllByRole('listitem') 71 | expect(weaponItems.length).toBeGreaterThan(0) 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /exercises/01.fetching/05.solution.use/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function getShip(name: string, delay?: number) { 6 | const searchParams = new URLSearchParams({ name }) 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 9 | if (!response.ok) { 10 | return Promise.reject(new Error(await response.text())) 11 | } 12 | const ship = await response.json() 13 | return ship as Ship 14 | } 15 | 16 | export function getImageUrlForShip( 17 | shipName: string, 18 | { size }: { size: number }, 19 | ) { 20 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 21 | } 22 | -------------------------------------------------------------------------------- /exercises/01.fetching/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Data fetching 2 | 3 | 4 | 5 | 👨‍💼 Hooray! Now we have a nice declarative loading/error state for loading data 6 | or really managing any async thing. Good work. 7 | -------------------------------------------------------------------------------- /exercises/02.dynamic/01.problem.cache/README.mdx: -------------------------------------------------------------------------------- 1 | # Promise Cache 2 | 3 | 4 | 5 | 🧝‍♂️ I've made a couple of changes. I moved the 6 | `getShip` call into the component itself so we can dynamically change which ship 7 | we're looking at. I also added some buttons to allow us to do this from the UI. 8 | 9 | But we've got a problem, and I've added a counter button to the UI so you can 10 | reproduce the problem. Every time you click it, we're suspending again 😱 This 11 | is because we don't have caching on our `getShip` function so every render 12 | triggers a new fetch call. 13 | 14 | 👨‍💼 Yeah, this is far from optimal for our application. Could you add a little 15 | cache for our `getShip` function so when it's called with the same ship name it 16 | doesn't request the ship again and suspend? 17 | 18 | When you're finished, you should be able to click the counter button as many 19 | times as you like without suspending. Additionally, because of the cache, once 20 | you've fetched the details for a ship, you should be able to switch back and 21 | forth and it should be instant. 22 | 23 | 24 | If you want to reset the cache while you're working on this, simply refresh 25 | the page. We're not going to get into cache invalidation in this workshop. 26 | 27 | 28 | 29 | The slightly inaccurate warning talked about in the video is no longer present 30 | in the latest version of React. Instead the requests will just continue for an 31 | infinite loop! This step will fix the infinite requests. 32 | 33 | -------------------------------------------------------------------------------- /exercises/02.dynamic/01.problem.cache/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/02.dynamic/01.problem.cache/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | // 🐨 create a shipCache Map that's got string keys and values are Promise 6 | 7 | // 🐨 export a new function called getShip (you'll rename the one below). 8 | // - it should take a name and optional delay number 9 | // - it should check the shipCache for the shipPromise by the name 10 | // - if it can't find one, it should call getShipImpl and store the promise in the cache 11 | // - then it should return the shipPromise 12 | 13 | // 🐨 rename this function to getShipImpl and remove the export 14 | export async function getShip(name: string, delay?: number) { 15 | const searchParams = new URLSearchParams({ name }) 16 | if (delay) searchParams.set('delay', String(delay)) 17 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 18 | if (!response.ok) { 19 | return Promise.reject(new Error(await response.text())) 20 | } 21 | const ship = await response.json() 22 | return ship as Ship 23 | } 24 | 25 | export function getImageUrlForShip( 26 | shipName: string, 27 | { size }: { size: number }, 28 | ) { 29 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 30 | } 31 | -------------------------------------------------------------------------------- /exercises/02.dynamic/01.solution.cache/README.mdx: -------------------------------------------------------------------------------- 1 | # Promise Cache 2 | 3 | 4 | 5 | 👨‍💼 Phew, that's much better! But our users are complaining about something else 6 | they don't like about this UX. Let's address that next. 7 | -------------------------------------------------------------------------------- /exercises/02.dynamic/01.solution.cache/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/02.dynamic/01.solution.cache/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, waitFor, waitForElementToBeRemoved, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('Data is loaded', async () => { 7 | await screen.findByRole('heading', { name: /Dreadnought/i }) 8 | await waitFor( 9 | () => expect(screen.queryByText('loading')).not.toBeInTheDocument(), 10 | { timeout: 5000 }, 11 | ) 12 | }) 13 | 14 | await testStep('Counter increments without suspending', async () => { 15 | const counterButton = await screen.findByText(/Click to re-render/) 16 | const initialCount = Number( 17 | counterButton.textContent?.match(/\d+/)?.[0] || '0', 18 | ) 19 | 20 | fireEvent.click(counterButton) 21 | 22 | await waitFor(() => { 23 | const updatedCount = Number( 24 | counterButton.textContent?.match(/\d+/)?.[0] || '0', 25 | ) 26 | expect(updatedCount).toBe(initialCount + 1) 27 | }) 28 | 29 | // Verify that no loading state is shown 30 | expect( 31 | screen.queryByText('loading'), 32 | '🚨 The loading state is shown after clicking increment', 33 | ).not.toBeInTheDocument() 34 | }) 35 | 36 | await testStep( 37 | 'Switching ships uses cache for previously loaded ships', 38 | async () => { 39 | // Switch to a new ship 40 | const interceptorButton = screen.getByRole('button', { 41 | name: 'Interceptor', 42 | }) 43 | fireEvent.click(interceptorButton) 44 | 45 | await waitFor( 46 | () => expect(screen.queryByText('loading')).not.toBeInTheDocument(), 47 | { timeout: 5000 }, 48 | ) 49 | 50 | // Switch back to the original ship 51 | const dreadnoughtButton = screen.getByRole('button', { 52 | name: 'Dreadnought', 53 | }) 54 | fireEvent.click(dreadnoughtButton) 55 | 56 | // Verify there's no loading state 57 | const loadingElement = await Promise.race([ 58 | screen.findByText('loading').catch(() => null), 59 | new Promise((resolve) => setTimeout(resolve, 1000)).then(() => null), 60 | ]) 61 | expect(loadingElement).toBeNull() 62 | 63 | const dreadnoughtImage = await screen.findByAltText('Dreadnought') 64 | if (!(dreadnoughtImage instanceof HTMLImageElement)) { 65 | throw new Error('Dreadnought image not found') 66 | } 67 | expect(dreadnoughtImage.src).not.toContain('/img/fallback-ship.png') 68 | }, 69 | ) 70 | -------------------------------------------------------------------------------- /exercises/02.dynamic/01.solution.cache/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | export function getImageUrlForShip( 25 | shipName: string, 26 | { size }: { size: number }, 27 | ) { 28 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 29 | } 30 | -------------------------------------------------------------------------------- /exercises/02.dynamic/02.problem.transition/README.mdx: -------------------------------------------------------------------------------- 1 | # useTransition 2 | 3 | 4 | 5 | 👨‍💼 Whenever we change the ship, our component suspends. But this is annoying to 6 | our users who are on reasonably fast internet connections because it means they 7 | see a loading spinner every time they change the ship when it really would be a 8 | better experience to have a more subtle pending state when the ship changes. 9 | 10 | So what we want is to keep the old UI around while the new UI is being worked 11 | on (and display it in a way that makes it look like it's loading). This is what 12 | `useTransition` is for! While React keeps the old UI around, it gives us a 13 | `isPending` state that we can use to show a loading spinner or something else 14 | while the new UI is loading: 15 | 16 | ```tsx 17 | const [isPending, startTransition] = useTransition() 18 | 19 | function handleSomeEvent() { 20 | startTransition(() => { 21 | // This state change triggers a component to suspend, so we wrap it in a 22 | // `startTransition` call to keep the old UI around until the new UI is ready. 23 | setSomeState(newState) 24 | }) 25 | } 26 | ``` 27 | 28 | Our designer just told us to use a `0.6` `opacity` setting while the ship is 29 | changing (for now). We can use the `useTransition` hook to accomplish this. 30 | 31 | So wrap the state update in a transition and add opacity to the details so we 32 | can give our users a better experience! 33 | -------------------------------------------------------------------------------- /exercises/02.dynamic/02.problem.transition/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/02.dynamic/02.problem.transition/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | export function getImageUrlForShip( 25 | shipName: string, 26 | { size }: { size: number }, 27 | ) { 28 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 29 | } 30 | -------------------------------------------------------------------------------- /exercises/02.dynamic/02.solution.transition/README.mdx: -------------------------------------------------------------------------------- 1 | # useTransition 2 | 3 | 4 | 5 | 👨‍💼 Yeah, isn't that a much better UX? Love it. Thanks! But there's one other 6 | issue we're having that some of our users brought up now... 7 | -------------------------------------------------------------------------------- /exercises/02.dynamic/02.solution.transition/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/02.dynamic/02.solution.transition/transition.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, waitFor, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('Initial ship is loaded', async () => { 7 | await screen.findByRole('heading', { name: /Dreadnought/i }) 8 | await waitFor( 9 | () => expect(screen.queryByText('loading')).not.toBeInTheDocument(), 10 | { timeout: 5000 }, 11 | ) 12 | }) 13 | 14 | async function findShipDetails() { 15 | return waitFor(() => { 16 | const details = document.querySelector('.details') 17 | if (!(details instanceof HTMLElement)) { 18 | throw new Error('Details not found') 19 | } 20 | return details 21 | }) 22 | } 23 | 24 | await testStep('Transition behavior when switching ships', async () => { 25 | const initialShipDetails = await findShipDetails() 26 | expect( 27 | initialShipDetails, 28 | '🚨 The initial ship details should initially have an opacity of 1', 29 | ).toHaveStyle({ opacity: '1' }) 30 | 31 | // Switch to a new ship 32 | const interceptorButton = screen.getByRole('button', { name: 'Interceptor' }) 33 | fireEvent.click(interceptorButton) 34 | 35 | const loadingElementPromise = Promise.race([ 36 | screen.findByText('loading').catch(() => null), 37 | new Promise((resolve) => setTimeout(resolve, 1000)).then(() => null), 38 | ]) 39 | 40 | // Check for pending state (opacity change) 41 | await waitFor(async () => { 42 | const pendingShipDetails = await findShipDetails() 43 | expect( 44 | pendingShipDetails, 45 | '🚨 The opacity is not being applied during the transition', 46 | ).toHaveStyle({ opacity: '0.6' }) 47 | }) 48 | 49 | expect( 50 | await loadingElementPromise, 51 | '🚨 No loading state should have been shown during the transition', 52 | ).toBeNull() 53 | 54 | // Wait for the new ship to load 55 | await screen.findByRole('heading', { name: /Interceptor/i }) 56 | 57 | // Check that opacity is back to 1 after loading 58 | await waitFor(async () => { 59 | const loadedShipDetails = await findShipDetails() 60 | expect( 61 | loadedShipDetails, 62 | '🚨 The opacity is not being applied after the transition', 63 | ).toHaveStyle({ opacity: '1' }) 64 | }) 65 | 66 | // Verify that no loading state is shown 67 | expect(screen.queryByText('loading')).not.toBeInTheDocument() 68 | }) 69 | -------------------------------------------------------------------------------- /exercises/02.dynamic/02.solution.transition/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | export function getImageUrlForShip( 25 | shipName: string, 26 | { size }: { size: number }, 27 | ) { 28 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 29 | } 30 | -------------------------------------------------------------------------------- /exercises/02.dynamic/03.problem.flash/README.mdx: -------------------------------------------------------------------------------- 1 | # Pending Flash 2 | 3 | 4 | 5 | 👨‍💼 When the user's on a reasonably fast connection, getting the ship data 6 | doesn't take very long and as a result the loading state doesn't show up for all 7 | that long. This is great, except it results in a flash of the loading state 8 | while the ship takes ~50ms to load. It's jarring for the user and it would be 9 | better if we could avoid it. 10 | 11 | We thought about adding a CSS delay of 300ms on the opacity transition, but that 12 | would just mean the flash of loading state happens for people for whom the data 13 | loading takes 350ms or so. Just moving the problem 🤷‍♂️ 14 | 15 | What we really need is something that can give us the following experience: 16 | 17 | - If the loading takes less than 300ms, don't show a loading spinner at all. 18 | - If the loading takes longer than 300ms, show a loading spinner for at least 19 | 350ms. Even if the loading is shorter than 300ms + 350ms. 20 | 21 | This way, if the user has a reasonably fast connection, they won't see a loading 22 | state at all, and if they have a medium fast connection, they'll be guaranteed 23 | to not get a flash of loading state because the loading state will show up for 24 | at least 350ms. 25 | 26 | Luckily, there's a simple package that does exactly this: 27 | [`spin-delay`](https://npm.im/spin-delay). Here's an example: 28 | 29 | ```tsx lines=1,8 30 | import { useSpinDelay } from 'spin-delay' 31 | 32 | function MyComponent() { 33 | const data = use(somePromise) 34 | const [isPending, startTransition] = useTransition() 35 | 36 | // options are optional, and default to these values 37 | const showSpinner = useSpinDelay(isPending, { delay: 500, minDuration: 200 }) 38 | 39 | if (showSpinner) { 40 | return 41 | } 42 | 43 | // ... 44 | } 45 | ``` 46 | 47 | So let's add this with a `delay` of `300` and a `minDuration` of `350` to the 48 | `Ship` component. 49 | 50 | To test this experience out, you can update the `getShip` call with a second 51 | argument which is a `delay` that will ensure the request takes at least that 52 | amount of time (it's not super precise, but it should give you an idea). 53 | -------------------------------------------------------------------------------- /exercises/02.dynamic/03.problem.flash/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/02.dynamic/03.problem.flash/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | export function getImageUrlForShip( 25 | shipName: string, 26 | { size }: { size: number }, 27 | ) { 28 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 29 | } 30 | -------------------------------------------------------------------------------- /exercises/02.dynamic/03.solution.flash/README.mdx: -------------------------------------------------------------------------------- 1 | # Pending Flash 2 | 3 | 4 | 5 | 👨‍💼 Great! You've managed to avoid a flash of loading state (for which our users 6 | are very grateful!) 7 | -------------------------------------------------------------------------------- /exercises/02.dynamic/03.solution.flash/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/02.dynamic/03.solution.flash/spin-delay.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, waitFor, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('Initial ship is loaded', async () => { 7 | await screen.findByRole('heading', { name: /Dreadnought/i }) 8 | await waitFor( 9 | () => expect(screen.queryByText('loading')).not.toBeInTheDocument(), 10 | { timeout: 5000 }, 11 | ) 12 | }) 13 | 14 | async function findShipDetails() { 15 | return waitFor(() => { 16 | const details = document.querySelector('.details') 17 | if (!(details instanceof HTMLElement)) { 18 | throw new Error('Details not found') 19 | } 20 | return details 21 | }) 22 | } 23 | 24 | await testStep('Spin-delay prevents loading states from flashing', async () => { 25 | const shipDetails = await findShipDetails() 26 | expect(shipDetails).toHaveStyle({ opacity: '1' }) 27 | 28 | // Switch to a new ship with a 200ms delay (less than 300ms threshold) 29 | const interceptorButton = screen.getByRole('button', { name: 'Interceptor' }) 30 | fireEvent.click(interceptorButton) 31 | 32 | const shipDetailsLoadingStatePromise = waitFor(() => { 33 | expect(shipDetails).toHaveStyle({ opacity: '0.6' }) 34 | }) 35 | 36 | const loadingStateWasShownPromise = Promise.race([ 37 | shipDetailsLoadingStatePromise.then( 38 | () => true, 39 | () => false, 40 | ), 41 | new Promise((resolve) => setTimeout(resolve, 1000)).then(() => false), 42 | ]) 43 | 44 | // Wait for the new ship to load 45 | await screen.findByRole('heading', { name: /Interceptor/i }) 46 | 47 | if (await loadingStateWasShownPromise) { 48 | throw new Error('🚨 The loading state was shown too quickly') 49 | } 50 | }) 51 | 52 | await testStep( 53 | 'Spin-delay still shows loading states when the switch takes too long', 54 | async () => { 55 | const shipDetails = await findShipDetails() 56 | // Switch to another ship with a 400ms delay (more than 300ms threshold) 57 | const galaxyCruiserButton = screen.getByRole('button', { 58 | name: 'Galaxy Cruiser', 59 | }) 60 | fireEvent.click(galaxyCruiserButton) 61 | 62 | // Check that the loading state is shown after 300ms 63 | await waitFor(() => { 64 | expect( 65 | shipDetails, 66 | '🚨 The opacity is not being applied during the transition', 67 | ).toHaveStyle({ opacity: '0.6' }) 68 | }) 69 | 70 | // Wait for the minimum duration (350ms) and check if loading state is still present 71 | await new Promise((resolve) => setTimeout(resolve, 250)) 72 | expect( 73 | shipDetails, 74 | '🚨 The opacity is not being applied long enough', 75 | ).toHaveStyle({ opacity: '0.6' }) 76 | }, 77 | ) 78 | -------------------------------------------------------------------------------- /exercises/02.dynamic/03.solution.flash/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | export function getImageUrlForShip( 25 | shipName: string, 26 | { size }: { size: number }, 27 | ) { 28 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 29 | } 30 | -------------------------------------------------------------------------------- /exercises/02.dynamic/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Dynamic Promises 2 | 3 | 4 | 5 | 👨‍💼 You've done well! You learned how to cache promises and how to show nice 6 | pending states while the user waits for the data. Great job. 7 | -------------------------------------------------------------------------------- /exercises/03.optimistic/01.problem.optimistic/README.mdx: -------------------------------------------------------------------------------- 1 | # Optimistic UI 2 | 3 | 4 | 5 | 🧝‍♂️ I've added a form and some API updates for us to be able to create new ships 6 | to add to the page. As usual, you can check my work 7 | if you're curious what has changed and if you'd like to implement this yourself 8 | for some extra practice, feel free to go back to the previous solution and add 9 | the form and API updates yourself. 10 | 11 | 👨‍💼 Thanks Kellie! Ok, so right now, the user experience is not great with the 12 | amount of time it takes to create a ship and then display the newly created 13 | ship. It definitely would be nice to let the user see as much of their newly 14 | created ship as possible while we're in the process of saving it and loading it. 15 | 16 | What we need is a mechanism for turning the user's submission into a Ship object 17 | which we can use to display. 18 | 19 | 🧝‍♂️ Actually, I already implemented this as well, it's called 20 | `createOptimisticShip` and accepts a `FormData` object. So you can use that. 21 | You'll notice the `fetchedAt` time is set to '...' because it's not technically 22 | been fetched yet. This is common for optimistic UI where there's some data you 23 | can't display until the server response actually comes through. But the rest of 24 | the data we get is what we want to display. 25 | 26 | 👨‍💼 Oh awesome. Great so what you need to do is create an optimistic ship which 27 | we can pass to the `ShipDetails` component to display that while the real data 28 | is being retrieved. 29 | 30 | You'll need to store this up in the `App` component and pass the 31 | `optimisticShip` and `setOptimisticShip` props down to the `CreateForm` and 32 | `ShipDetails` components. 33 | 34 | Hop to it! 35 | -------------------------------------------------------------------------------- /exercises/03.optimistic/01.problem.optimistic/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips, createShip } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | 37 | export async function action({ 38 | request, 39 | params, 40 | }: { 41 | request: Request 42 | params: Record 43 | }) { 44 | const path = params['*'] 45 | switch (path) { 46 | case 'create-ship': { 47 | return createShip(request) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /exercises/03.optimistic/01.problem.optimistic/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function createShip(formData: FormData, delay?: number) { 6 | const searchParams = new URLSearchParams() 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const r = await fetch(`api/create-ship?${searchParams.toString()}`, { 9 | method: 'POST', 10 | body: formData, 11 | }) 12 | if (!r.ok) { 13 | throw new Error(await r.text()) 14 | } 15 | } 16 | 17 | const shipCache = new Map>() 18 | 19 | export function getShip(name: string, delay?: number) { 20 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 21 | shipCache.set(name, shipPromise) 22 | return shipPromise 23 | } 24 | 25 | async function getShipImpl(name: string, delay?: number) { 26 | const searchParams = new URLSearchParams({ name }) 27 | if (delay) searchParams.set('delay', String(delay)) 28 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 29 | if (!response.ok) { 30 | return Promise.reject(new Error(await response.text())) 31 | } 32 | const ship = await response.json() 33 | return ship as Ship 34 | } 35 | 36 | export function getImageUrlForShip( 37 | shipName: string, 38 | { size }: { size: number }, 39 | ) { 40 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 41 | } 42 | -------------------------------------------------------------------------------- /exercises/03.optimistic/01.solution.optimistic/README.mdx: -------------------------------------------------------------------------------- 1 | # Optimistic UI 2 | 3 | 4 | 5 | 👨‍💼 Great work! Now the user experience of creating a ship is so much better! 6 | -------------------------------------------------------------------------------- /exercises/03.optimistic/01.solution.optimistic/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips, createShip } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | 37 | export async function action({ 38 | request, 39 | params, 40 | }: { 41 | request: Request 42 | params: Record 43 | }) { 44 | const path = params['*'] 45 | switch (path) { 46 | case 'create-ship': { 47 | return createShip(request) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /exercises/03.optimistic/01.solution.optimistic/optimistic.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, waitFor, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('Initial ship details are loaded', async () => { 7 | await screen.findByRole('heading', { name: /Dreadnought/i }) 8 | await waitFor( 9 | () => expect(screen.queryAllByText('loading')).toHaveLength(0), 10 | { timeout: 5000 }, 11 | ) 12 | }) 13 | 14 | await testStep('Create form renders correctly', async () => { 15 | expect(screen.getByLabelText('Ship Name')).toBeInTheDocument() 16 | expect(screen.getByLabelText('Top Speed')).toBeInTheDocument() 17 | expect(screen.getByLabelText('Image')).toBeInTheDocument() 18 | expect(screen.getByRole('button', { name: /Create/i })).toBeInTheDocument() 19 | }) 20 | 21 | const shipName = 'New Test Ship' 22 | const topSpeed = '9999' 23 | await testStep('Form submission functions', async () => { 24 | fireEvent.change(screen.getByLabelText(/Ship Name/i), { 25 | target: { value: shipName }, 26 | }) 27 | fireEvent.change(screen.getByLabelText(/Top Speed/i), { 28 | target: { value: topSpeed }, 29 | }) 30 | 31 | // We can't actually select files via JavaScript, so we need to remove the required attribute 32 | const imageInput = screen.getByLabelText(/Image/i) 33 | imageInput.removeAttribute('required') 34 | 35 | fireEvent.click(screen.getByRole('button', { name: /Create/i })) 36 | }) 37 | 38 | await testStep('Optimistic UI updates when creating a new ship', async () => { 39 | // wait just a bit for the optimistic update to happen 40 | await new Promise((resolve) => setTimeout(resolve, 100)) 41 | // Check for optimistic update 42 | expect( 43 | screen.getByRole('heading', { name: new RegExp(shipName, 'i') }), 44 | '🚨 The optimistic update for the heading title is missing', 45 | ).toBeInTheDocument() 46 | expect( 47 | screen.getByText(new RegExp(topSpeed, 'i'), { exact: false }), 48 | '🚨 The optimistic update for the top speed is missing', 49 | ).toBeInTheDocument() 50 | // can't verify the image because we can't select files via JavaScript 51 | }) 52 | 53 | await testStep( 54 | 'When the form submission succeeds, the final result is displayed', 55 | async () => { 56 | // Wait for the actual data to load 57 | await waitFor( 58 | () => expect(screen.queryByText('...')).not.toBeInTheDocument(), 59 | { timeout: 5000 }, 60 | ) 61 | 62 | await screen.findByRole('heading', { name: new RegExp(shipName, 'i') }) 63 | await screen.findByText(new RegExp(topSpeed, 'i')) 64 | }, 65 | ) 66 | -------------------------------------------------------------------------------- /exercises/03.optimistic/01.solution.optimistic/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function createShip(formData: FormData, delay?: number) { 6 | const searchParams = new URLSearchParams() 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const r = await fetch(`api/create-ship?${searchParams.toString()}`, { 9 | method: 'POST', 10 | body: formData, 11 | }) 12 | if (!r.ok) { 13 | throw new Error(await r.text()) 14 | } 15 | } 16 | 17 | const shipCache = new Map>() 18 | 19 | export function getShip(name: string, delay?: number) { 20 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 21 | shipCache.set(name, shipPromise) 22 | return shipPromise 23 | } 24 | 25 | async function getShipImpl(name: string, delay?: number) { 26 | const searchParams = new URLSearchParams({ name }) 27 | if (delay) searchParams.set('delay', String(delay)) 28 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 29 | if (!response.ok) { 30 | return Promise.reject(new Error(await response.text())) 31 | } 32 | const ship = await response.json() 33 | return ship as Ship 34 | } 35 | 36 | export function getImageUrlForShip( 37 | shipName: string, 38 | { size }: { size: number }, 39 | ) { 40 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 41 | } 42 | -------------------------------------------------------------------------------- /exercises/03.optimistic/02.problem.form-status/README.mdx: -------------------------------------------------------------------------------- 1 | # Form Status 2 | 3 | 4 | 5 | 👨‍💼 It would be nice if we update the create button with a message letting the 6 | user know that we're in the process of creating their ship and also disable it 7 | to prevent the user from clicking it again by mistake. 8 | 9 | Can you use `useFormStatus` to do this? 10 | 11 | - [📜 `useFormStatus`](https://react.dev/reference/react-dom/hooks/useFormStatus) 12 | -------------------------------------------------------------------------------- /exercises/03.optimistic/02.problem.form-status/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips, createShip } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | 37 | export async function action({ 38 | request, 39 | params, 40 | }: { 41 | request: Request 42 | params: Record 43 | }) { 44 | const path = params['*'] 45 | switch (path) { 46 | case 'create-ship': { 47 | return createShip(request) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /exercises/03.optimistic/02.problem.form-status/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function createShip(formData: FormData, delay?: number) { 6 | const searchParams = new URLSearchParams() 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const r = await fetch(`api/create-ship?${searchParams.toString()}`, { 9 | method: 'POST', 10 | body: formData, 11 | }) 12 | if (!r.ok) { 13 | throw new Error(await r.text()) 14 | } 15 | } 16 | 17 | const shipCache = new Map>() 18 | 19 | export function getShip(name: string, delay?: number) { 20 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 21 | shipCache.set(name, shipPromise) 22 | return shipPromise 23 | } 24 | 25 | async function getShipImpl(name: string, delay?: number) { 26 | const searchParams = new URLSearchParams({ name }) 27 | if (delay) searchParams.set('delay', String(delay)) 28 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 29 | if (!response.ok) { 30 | return Promise.reject(new Error(await response.text())) 31 | } 32 | const ship = await response.json() 33 | return ship as Ship 34 | } 35 | 36 | export function getImageUrlForShip( 37 | shipName: string, 38 | { size }: { size: number }, 39 | ) { 40 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 41 | } 42 | -------------------------------------------------------------------------------- /exercises/03.optimistic/02.solution.form-status/README.mdx: -------------------------------------------------------------------------------- 1 | # Form Status 2 | 3 | 4 | 5 | 👨‍💼 Great! This is a nice improvement to the UX of our form. Thanks! 6 | -------------------------------------------------------------------------------- /exercises/03.optimistic/02.solution.form-status/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips, createShip } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | 37 | export async function action({ 38 | request, 39 | params, 40 | }: { 41 | request: Request 42 | params: Record 43 | }) { 44 | const path = params['*'] 45 | switch (path) { 46 | case 'create-ship': { 47 | return createShip(request) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /exercises/03.optimistic/02.solution.form-status/status.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, waitFor, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('Initial ship details are loaded', async () => { 7 | await screen.findByRole('heading', { name: /Dreadnought/i }) 8 | await waitFor( 9 | () => expect(screen.queryAllByText('loading')).toHaveLength(0), 10 | { timeout: 5000 }, 11 | ) 12 | }) 13 | 14 | const createButton = await testStep( 15 | 'Create form renders correctly', 16 | async () => { 17 | expect(screen.getByLabelText('Ship Name')).toBeInTheDocument() 18 | expect(screen.getByLabelText('Top Speed')).toBeInTheDocument() 19 | expect(screen.getByLabelText('Image')).toBeInTheDocument() 20 | const createButton = screen.getByRole('button', { name: /Create/i }) 21 | expect(createButton).toBeInTheDocument() 22 | return createButton 23 | }, 24 | ) 25 | 26 | const shipName = 'New Test Ship' 27 | const topSpeed = '9999' 28 | await testStep('Form submission functions', async () => { 29 | fireEvent.change(screen.getByLabelText(/Ship Name/i), { 30 | target: { value: shipName }, 31 | }) 32 | fireEvent.change(screen.getByLabelText(/Top Speed/i), { 33 | target: { value: topSpeed }, 34 | }) 35 | 36 | // We can't actually select files via JavaScript, so we need to remove the required attribute 37 | const imageInput = screen.getByLabelText(/Image/i) 38 | imageInput.removeAttribute('required') 39 | 40 | fireEvent.click(createButton) 41 | }) 42 | 43 | await testStep( 44 | 'Create button shows "Creating..." during submission', 45 | async () => { 46 | await waitFor(() => { 47 | expect( 48 | createButton, 49 | '🚨 The create button should show "Creating..." when the form is submitted', 50 | ).toHaveTextContent('Creating...') 51 | }) 52 | }, 53 | ) 54 | 55 | await testStep('Create button shows "Create" after submission', async () => { 56 | await waitFor( 57 | () => { 58 | expect( 59 | createButton, 60 | '🚨 The create button should show "Create" when the request is complete', 61 | ).toHaveTextContent('Create') 62 | }, 63 | { timeout: 5000 }, 64 | ) 65 | }) 66 | -------------------------------------------------------------------------------- /exercises/03.optimistic/02.solution.form-status/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function createShip(formData: FormData, delay?: number) { 6 | const searchParams = new URLSearchParams() 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const r = await fetch(`api/create-ship?${searchParams.toString()}`, { 9 | method: 'POST', 10 | body: formData, 11 | }) 12 | if (!r.ok) { 13 | throw new Error(await r.text()) 14 | } 15 | } 16 | 17 | const shipCache = new Map>() 18 | 19 | export function getShip(name: string, delay?: number) { 20 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 21 | shipCache.set(name, shipPromise) 22 | return shipPromise 23 | } 24 | 25 | async function getShipImpl(name: string, delay?: number) { 26 | const searchParams = new URLSearchParams({ name }) 27 | if (delay) searchParams.set('delay', String(delay)) 28 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 29 | if (!response.ok) { 30 | return Promise.reject(new Error(await response.text())) 31 | } 32 | const ship = await response.json() 33 | return ship as Ship 34 | } 35 | 36 | export function getImageUrlForShip( 37 | shipName: string, 38 | { size }: { size: number }, 39 | ) { 40 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 41 | } 42 | -------------------------------------------------------------------------------- /exercises/03.optimistic/03.problem.message/README.mdx: -------------------------------------------------------------------------------- 1 | # Multi-step Actions 2 | 3 | 4 | 5 | 👨‍💼 Our submit button changes from "Create" to "Creating..." but our form action 6 | actually has one more step: loading the newly created ship. 7 | 8 | We want to update the message of our submit button to indicate which step we're 9 | on in the process. But updating state during a transition (like that in our form 10 | action) isn't possible. So we need to use `useOptimistic`. Here's an example of 11 | how you might do this: 12 | 13 | ```tsx 14 |
{ 16 | setMessage('Creating order...') 17 | const order = await createOrder(formData) 18 | setMessage('Creating payment...') 19 | const payment = await createPayment(formData, order) 20 | setMessage('Almost done!') 21 | await sendThankYou(order, payment) 22 | }} 23 | > 24 | {/* ...*/} 25 |
26 | ``` 27 | 28 | So please add a `useOptimistic` for a "message" variable which we'll use to 29 | update the submit button message. You can initialize it to "Create" and then 30 | update it in each step of our form action. 31 | -------------------------------------------------------------------------------- /exercises/03.optimistic/03.problem.message/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips, createShip } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | 37 | export async function action({ 38 | request, 39 | params, 40 | }: { 41 | request: Request 42 | params: Record 43 | }) { 44 | const path = params['*'] 45 | switch (path) { 46 | case 'create-ship': { 47 | return createShip(request) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /exercises/03.optimistic/03.problem.message/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function createShip(formData: FormData, delay?: number) { 6 | const searchParams = new URLSearchParams() 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const r = await fetch(`api/create-ship?${searchParams.toString()}`, { 9 | method: 'POST', 10 | body: formData, 11 | }) 12 | if (!r.ok) { 13 | throw new Error(await r.text()) 14 | } 15 | } 16 | 17 | const shipCache = new Map>() 18 | 19 | export function getShip(name: string, delay?: number) { 20 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 21 | shipCache.set(name, shipPromise) 22 | return shipPromise 23 | } 24 | 25 | async function getShipImpl(name: string, delay?: number) { 26 | const searchParams = new URLSearchParams({ name }) 27 | if (delay) searchParams.set('delay', String(delay)) 28 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 29 | if (!response.ok) { 30 | return Promise.reject(new Error(await response.text())) 31 | } 32 | const ship = await response.json() 33 | return ship as Ship 34 | } 35 | 36 | export function getImageUrlForShip( 37 | shipName: string, 38 | { size }: { size: number }, 39 | ) { 40 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 41 | } 42 | -------------------------------------------------------------------------------- /exercises/03.optimistic/03.solution.message/README.mdx: -------------------------------------------------------------------------------- 1 | # Multi-step Actions 2 | 3 | 4 | 5 | 👨‍💼 Great! `useOptimistic` is a wonderful way to make state changes during the 6 | transitions of our suspending components! 7 | -------------------------------------------------------------------------------- /exercises/03.optimistic/03.solution.message/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips, createShip } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | 37 | export async function action({ 38 | request, 39 | params, 40 | }: { 41 | request: Request 42 | params: Record 43 | }) { 44 | const path = params['*'] 45 | switch (path) { 46 | case 'create-ship': { 47 | return createShip(request) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /exercises/03.optimistic/03.solution.message/message.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, waitFor, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('Initial ship details are loaded', async () => { 7 | await screen.findByRole('heading', { name: /Dreadnought/i }) 8 | await waitFor( 9 | () => expect(screen.queryAllByText('loading')).toHaveLength(0), 10 | { timeout: 5000 }, 11 | ) 12 | }) 13 | 14 | const createButton = await testStep( 15 | 'Create form renders correctly', 16 | async () => { 17 | expect(screen.getByLabelText('Ship Name')).toBeInTheDocument() 18 | expect(screen.getByLabelText('Top Speed')).toBeInTheDocument() 19 | expect(screen.getByLabelText('Image')).toBeInTheDocument() 20 | const createButton = screen.getByRole('button', { name: /Create/i }) 21 | expect(createButton).toBeInTheDocument() 22 | return createButton 23 | }, 24 | ) 25 | 26 | const shipName = 'New Test Ship' 27 | const topSpeed = '9999' 28 | await testStep('Form submission functions', async () => { 29 | fireEvent.change(screen.getByLabelText(/Ship Name/i), { 30 | target: { value: shipName }, 31 | }) 32 | fireEvent.change(screen.getByLabelText(/Top Speed/i), { 33 | target: { value: topSpeed }, 34 | }) 35 | 36 | // We can't actually select files via JavaScript, so we need to remove the required attribute 37 | const imageInput = screen.getByLabelText(/Image/i) 38 | imageInput.removeAttribute('required') 39 | 40 | fireEvent.click(createButton) 41 | }) 42 | 43 | await testStep( 44 | 'Create button shows "Creating..." during submission', 45 | async () => { 46 | await waitFor(() => { 47 | expect( 48 | createButton, 49 | '🚨 The create button should show "Creating..." when the form is submitted', 50 | ).toHaveTextContent('Creating...') 51 | }) 52 | }, 53 | ) 54 | 55 | await testStep( 56 | 'Create button shows "Created! Loading..." after creation', 57 | async () => { 58 | await waitFor( 59 | () => { 60 | expect( 61 | createButton, 62 | '🚨 The create button should show "Created! Loading..." after the ship is created', 63 | ).toHaveTextContent('Created! Loading...') 64 | }, 65 | { timeout: 5000 }, 66 | ) 67 | }, 68 | ) 69 | 70 | await testStep( 71 | 'Create button shows "Create" after full submission', 72 | async () => { 73 | await waitFor( 74 | () => { 75 | expect( 76 | createButton, 77 | '🚨 The create button should show "Create" when the request is complete', 78 | ).toHaveTextContent('Create') 79 | }, 80 | { timeout: 5000 }, 81 | ) 82 | }, 83 | ) 84 | 85 | await testStep('New ship is displayed', async () => { 86 | await screen.findByRole('heading', { name: new RegExp(shipName, 'i') }) 87 | }) 88 | -------------------------------------------------------------------------------- /exercises/03.optimistic/03.solution.message/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | export async function createShip(formData: FormData, delay?: number) { 6 | const searchParams = new URLSearchParams() 7 | if (delay) searchParams.set('delay', String(delay)) 8 | const r = await fetch(`api/create-ship?${searchParams.toString()}`, { 9 | method: 'POST', 10 | body: formData, 11 | }) 12 | if (!r.ok) { 13 | throw new Error(await r.text()) 14 | } 15 | } 16 | 17 | const shipCache = new Map>() 18 | 19 | export function getShip(name: string, delay?: number) { 20 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 21 | shipCache.set(name, shipPromise) 22 | return shipPromise 23 | } 24 | 25 | async function getShipImpl(name: string, delay?: number) { 26 | const searchParams = new URLSearchParams({ name }) 27 | if (delay) searchParams.set('delay', String(delay)) 28 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 29 | if (!response.ok) { 30 | return Promise.reject(new Error(await response.text())) 31 | } 32 | const ship = await response.json() 33 | return ship as Ship 34 | } 35 | 36 | export function getImageUrlForShip( 37 | shipName: string, 38 | { size }: { size: number }, 39 | ) { 40 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 41 | } 42 | -------------------------------------------------------------------------------- /exercises/03.optimistic/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Optimistic UI 2 | 3 | 4 | 5 | 👨‍💼 You're doing awesome. I'm so optimistic about the future! 6 | -------------------------------------------------------------------------------- /exercises/04.image/01.problem.img/README.mdx: -------------------------------------------------------------------------------- 1 | # Img Component 2 | 3 | 4 | 5 | 👨‍💼 Our users have noticed this annoying behavior. Here's how you reproduce it: 6 | 7 | {/* prettier-ignore */} 8 | - Set the playground to this exercise 9 | - Open the playground (in a separate tab) 10 | - Open the dev tools 11 | - Notice that we've added a version query parameter to the images to simulate 12 | initial loading. This is why you see a different versions in the URL for 13 | each image. 14 | - [Throttle your network](https://developer.chrome.com/docs/devtools/network/reference#throttling) speed in the dev tools to "Slow 3G" 15 | - Click a separate ship 16 | 17 | 18 | 🚨 While you're here, make sure you have `Disable cache` *unchecked* in the 19 | Network tab of dev tools. Otherwise, the browser will ignore your preloaded 20 | image and try to re-fetch it, bypassing your solution. 21 | 22 | 23 | You should notice the network loads the data for the ship. While that happens, 24 | our pending state is shown (great job on that again). Once the data is loaded, 25 | the component re-renders with the new data (even the `img` `src` gets updated 26 | properly). However, the `img` is still loading. This is because the browser 27 | waits for the new image `src` to be loaded before switching to the new image. 28 | 29 | Our users are confused by this, and that's what we need you to solve using 30 | Suspense, the `use` hook, and a `preloadImage` utility. 31 | 32 | The emoji will guide you through this one. 33 | Best to start in . Lots of this will feel similar 34 | to what we were doing with the `getShip` regarding the caching stuff! 35 | 36 | 37 | 🚨 Note, this is extremely difficult to test, so the test may be unreliable. 38 | However, if you do throttle the network, then the test will be much more 39 | reliable. 40 | 41 | -------------------------------------------------------------------------------- /exercises/04.image/01.problem.img/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/04.image/01.problem.img/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | 5 | * { 6 | box-sizing: border-box; 7 | } 8 | 9 | .app-wrapper { 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | justify-content: center; 14 | height: 100vh; 15 | } 16 | 17 | .ship-buttons { 18 | display: flex; 19 | justify-content: space-between; 20 | width: 300px; 21 | padding-bottom: 10px; 22 | } 23 | 24 | .ship-buttons button { 25 | border-radius: 2px; 26 | padding: 2px 4px; 27 | font-size: 0.75rem; 28 | background-color: white; 29 | color: black; 30 | &:not(.active) { 31 | box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.5); 32 | } 33 | &.active { 34 | box-shadow: inset 0 0 4px 0 rgba(0, 0, 0, 0.5); 35 | } 36 | } 37 | 38 | .app { 39 | display: flex; 40 | max-width: 1024px; 41 | border: 1px solid #000; 42 | border-start-end-radius: 0.5rem; 43 | border-start-start-radius: 0.5rem; 44 | border-end-start-radius: 50% 8%; 45 | border-end-end-radius: 50% 8%; 46 | overflow: hidden; 47 | } 48 | 49 | .search { 50 | width: 150px; 51 | max-height: 400px; 52 | overflow: hidden; 53 | display: flex; 54 | flex-direction: column; 55 | 56 | input { 57 | width: 100%; 58 | border: 0; 59 | border-bottom: 1px solid #000; 60 | padding: 8px; 61 | line-height: 1.5; 62 | border-top-left-radius: 0.5rem; 63 | } 64 | 65 | ul { 66 | flex: 1; 67 | list-style: none; 68 | padding: 4px; 69 | padding-bottom: 30px; 70 | margin: 0; 71 | display: flex; 72 | flex-direction: column; 73 | gap: 8px; 74 | overflow-y: auto; 75 | li { 76 | button { 77 | display: flex; 78 | align-items: center; 79 | gap: 4px; 80 | border: none; 81 | background-color: transparent; 82 | &:hover { 83 | text-decoration: underline; 84 | } 85 | img { 86 | width: 20px; 87 | height: 20px; 88 | object-fit: contain; 89 | border-radius: 50%; 90 | } 91 | } 92 | } 93 | } 94 | } 95 | 96 | .details { 97 | flex: 1; 98 | height: 400px; 99 | position: relative; 100 | overflow: hidden; 101 | } 102 | 103 | .ship-info { 104 | height: 100%; 105 | width: 300px; 106 | margin: auto; 107 | overflow: auto; 108 | background-color: #eee; 109 | border-radius: 4px; 110 | padding: 20px; 111 | position: relative; 112 | } 113 | 114 | .ship-info.ship-loading { 115 | opacity: 0.6; 116 | } 117 | 118 | .ship-info h2 { 119 | font-weight: bold; 120 | text-align: center; 121 | margin-top: 0.3em; 122 | } 123 | 124 | .ship-info img { 125 | width: 100%; 126 | height: 100%; 127 | aspect-ratio: 1; 128 | object-fit: contain; 129 | } 130 | 131 | .ship-info .ship-info__img-wrapper { 132 | margin-top: 20px; 133 | width: 100%; 134 | height: 200px; 135 | } 136 | 137 | .ship-info .ship-info__fetch-time { 138 | position: absolute; 139 | top: 6px; 140 | right: 10px; 141 | } 142 | 143 | .app-error { 144 | position: relative; 145 | background-image: url('/img/broken-ship.webp'); 146 | background-size: contain; 147 | background-repeat: no-repeat; 148 | background-position: center; 149 | width: 400px; 150 | height: 400px; 151 | p { 152 | position: absolute; 153 | top: 30%; 154 | left: 50%; 155 | transform: translate(-50%, -50%); 156 | background-color: white; 157 | padding: 6px 12px; 158 | border-radius: 1rem; 159 | font-size: 1.5rem; 160 | font-weight: bold; 161 | width: 300px; 162 | text-align: center; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /exercises/04.image/01.problem.img/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | // 🐨 create an imgCache here that's a map of string and Promise 25 | 26 | // 🐨 export a function called imgSrc that takes a src string 27 | // - check if there's a imgPromise in the imgCache for the src, if not, create one with preloadImage(src) 28 | // - set the imgPromise in the imgCache 29 | // - return the imgPromise 30 | 31 | // 💰 here's a function you can use to wait for the image to be ready to display 32 | // function preloadImage(src: string) { 33 | // return new Promise(async (resolve, reject) => { 34 | // const img = new Image() 35 | // img.src = src 36 | // img.onload = () => resolve(src) 37 | // img.onerror = reject 38 | // }) 39 | // } 40 | 41 | // added the version to prevent caching to make testing easier 42 | const version = Date.now() 43 | 44 | export function getImageUrlForShip( 45 | shipName: string, 46 | { size }: { size: number }, 47 | ) { 48 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}&v=${version}` 49 | } 50 | -------------------------------------------------------------------------------- /exercises/04.image/01.solution.img/README.mdx: -------------------------------------------------------------------------------- 1 | # Img Component 2 | 3 | 4 | 5 | 👨‍💼 Great! If you try the reproduction steps again you should notice that we 6 | don't render the data until the image is ready to be shown which reduces our 7 | users' confusion! Well done! 8 | 9 | Unfortunately, we've got a new problem... Let's look at that next. 10 | -------------------------------------------------------------------------------- /exercises/04.image/01.solution.img/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/04.image/01.solution.img/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | 5 | * { 6 | box-sizing: border-box; 7 | } 8 | 9 | .app-wrapper { 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | justify-content: center; 14 | height: 100vh; 15 | } 16 | 17 | .ship-buttons { 18 | display: flex; 19 | justify-content: space-between; 20 | width: 300px; 21 | padding-bottom: 10px; 22 | } 23 | 24 | .ship-buttons button { 25 | border-radius: 2px; 26 | padding: 2px 4px; 27 | font-size: 0.75rem; 28 | background-color: white; 29 | color: black; 30 | &:not(.active) { 31 | box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.5); 32 | } 33 | &.active { 34 | box-shadow: inset 0 0 4px 0 rgba(0, 0, 0, 0.5); 35 | } 36 | } 37 | 38 | .app { 39 | display: flex; 40 | max-width: 1024px; 41 | border: 1px solid #000; 42 | border-start-end-radius: 0.5rem; 43 | border-start-start-radius: 0.5rem; 44 | border-end-start-radius: 50% 8%; 45 | border-end-end-radius: 50% 8%; 46 | overflow: hidden; 47 | } 48 | 49 | .search { 50 | width: 150px; 51 | max-height: 400px; 52 | overflow: hidden; 53 | display: flex; 54 | flex-direction: column; 55 | 56 | input { 57 | width: 100%; 58 | border: 0; 59 | border-bottom: 1px solid #000; 60 | padding: 8px; 61 | line-height: 1.5; 62 | border-top-left-radius: 0.5rem; 63 | } 64 | 65 | ul { 66 | flex: 1; 67 | list-style: none; 68 | padding: 4px; 69 | padding-bottom: 30px; 70 | margin: 0; 71 | display: flex; 72 | flex-direction: column; 73 | gap: 8px; 74 | overflow-y: auto; 75 | li { 76 | button { 77 | display: flex; 78 | align-items: center; 79 | gap: 4px; 80 | border: none; 81 | background-color: transparent; 82 | &:hover { 83 | text-decoration: underline; 84 | } 85 | img { 86 | width: 20px; 87 | height: 20px; 88 | object-fit: contain; 89 | border-radius: 50%; 90 | } 91 | } 92 | } 93 | } 94 | } 95 | 96 | .details { 97 | flex: 1; 98 | height: 400px; 99 | position: relative; 100 | overflow: hidden; 101 | } 102 | 103 | .ship-info { 104 | height: 100%; 105 | width: 300px; 106 | margin: auto; 107 | overflow: auto; 108 | background-color: #eee; 109 | border-radius: 4px; 110 | padding: 20px; 111 | position: relative; 112 | } 113 | 114 | .ship-info.ship-loading { 115 | opacity: 0.6; 116 | } 117 | 118 | .ship-info h2 { 119 | font-weight: bold; 120 | text-align: center; 121 | margin-top: 0.3em; 122 | } 123 | 124 | .ship-info img { 125 | width: 100%; 126 | height: 100%; 127 | aspect-ratio: 1; 128 | object-fit: contain; 129 | } 130 | 131 | .ship-info .ship-info__img-wrapper { 132 | margin-top: 20px; 133 | width: 100%; 134 | height: 200px; 135 | } 136 | 137 | .ship-info .ship-info__fetch-time { 138 | position: absolute; 139 | top: 6px; 140 | right: 10px; 141 | } 142 | 143 | .app-error { 144 | position: relative; 145 | background-image: url('/img/broken-ship.webp'); 146 | background-size: contain; 147 | background-repeat: no-repeat; 148 | background-position: center; 149 | width: 400px; 150 | height: 400px; 151 | p { 152 | position: absolute; 153 | top: 30%; 154 | left: 50%; 155 | transform: translate(-50%, -50%); 156 | background-color: white; 157 | padding: 6px 12px; 158 | border-radius: 1rem; 159 | font-size: 1.5rem; 160 | font-weight: bold; 161 | width: 300px; 162 | text-align: center; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /exercises/04.image/01.solution.img/suspense-img.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, waitFor, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | function onLoad(img: HTMLElement, cb?: () => void) { 7 | return new Promise((resolve) => { 8 | img.addEventListener( 9 | 'load', 10 | () => { 11 | cb?.() 12 | resolve() 13 | }, 14 | { once: true }, 15 | ) 16 | }) 17 | } 18 | 19 | await testStep('Initial ship details are loaded', async () => { 20 | await screen.findByRole('heading', { name: /Dreadnought/i }) 21 | await waitFor( 22 | () => expect(screen.queryAllByText('loading')).toHaveLength(0), 23 | { timeout: 5000 }, 24 | ) 25 | await onLoad(screen.getByAltText('Dreadnought')) 26 | await new Promise((resolve) => setTimeout(resolve, 250)) 27 | }) 28 | 29 | const img = await testStep('Initial image is loaded', async () => { 30 | const img = await screen.findByAltText('Dreadnought') 31 | expect(img, '🚨 Initial image should be present').toBeInTheDocument() 32 | return img 33 | }) 34 | 35 | const initialSrc = img.getAttribute('src') 36 | 37 | let loadTime = 0, 38 | srcChangeTime = 0 39 | 40 | const loadAndSrcChange = Promise.all([ 41 | onLoad(img).then(() => { 42 | loadTime = Date.now() 43 | }), 44 | waitFor( 45 | () => { 46 | expect(img.getAttribute('src')).not.toBe(initialSrc) 47 | srcChangeTime = Date.now() 48 | }, 49 | { timeout: 4000 }, 50 | ), 51 | ]) 52 | 53 | await testStep('Click on a different ship', async () => { 54 | fireEvent.click(screen.getByRole('button', { name: 'Interceptor' })) 55 | }) 56 | 57 | await testStep('New ship details are loaded', async () => { 58 | await screen.findByRole( 59 | 'heading', 60 | { name: /Interceptor/i }, 61 | { timeout: 4000 }, 62 | ) 63 | }) 64 | 65 | const newImg = await testStep('New image element is present', async () => { 66 | const img = await screen.findByAltText('Interceptor') 67 | expect(img, '🚨 New image element should be present').toBeInTheDocument() 68 | return img 69 | }) 70 | 71 | await testStep('Verify image src has changed', async () => { 72 | await waitFor(() => { 73 | expect( 74 | newImg.getAttribute('src'), 75 | '🚨 Image src should change after load event', 76 | ).not.toBe(initialSrc) 77 | expect( 78 | newImg.getAttribute('src'), 79 | '🚨 New image src should contain "interceptor"', 80 | ).toMatch(/interceptor/) 81 | }) 82 | }) 83 | 84 | // put this last so it doesn't affect the other steps 85 | await testStep('img was loaded before the src changed.', async () => { 86 | await loadAndSrcChange 87 | console.log(loadTime - srcChangeTime) 88 | expect( 89 | loadTime - srcChangeTime, 90 | '🚨 the img should be loaded before the src changes. Make sure to suspend until the img is loaded.', 91 | ).toBeLessThan(4) 92 | }) 93 | -------------------------------------------------------------------------------- /exercises/04.image/01.solution.img/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | const imgCache = new Map>() 25 | 26 | export function imgSrc(src: string) { 27 | const imgPromise = imgCache.get(src) ?? preloadImage(src) 28 | imgCache.set(src, imgPromise) 29 | return imgPromise 30 | } 31 | 32 | function preloadImage(src: string) { 33 | return new Promise(async (resolve, reject) => { 34 | const img = new Image() 35 | img.src = src 36 | img.onload = () => resolve(src) 37 | img.onerror = reject 38 | }) 39 | } 40 | 41 | // added the version to prevent caching to make testing easier 42 | const version = Date.now() 43 | 44 | export function getImageUrlForShip( 45 | shipName: string, 46 | { size }: { size: number }, 47 | ) { 48 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}&v=${version}` 49 | } 50 | -------------------------------------------------------------------------------- /exercises/04.image/02.problem.error/README.mdx: -------------------------------------------------------------------------------- 1 | # Img Error Boundary 2 | 3 | 4 | 5 | 👨‍💼 Sometimes images fail to load for one reason or another. Maybe the URL had 6 | a typo in it or maybe the user has a spotty network connection. Right now, when 7 | the image fails to load, we don't show anything to the user at all. We just go 8 | to the nearest error boundary and show that. 9 | 10 | 🧝‍♂️ I've added a bit of code to make the image fail 11 | to load and you'll notice that when that happens, we just fallback to the error 12 | boundary for the entire component, even though we have some useful information 13 | that we could show the user. 14 | 15 | 👨‍💼 What I'd like you to do is create a `ShipImg` component which renders an 16 | `ErrorBoundary` around an `Img` component. For the fallback, you can simply 17 | render a regular `` tag and forward the regular props. This will cause 18 | the browser to display what it normally does when an image fails to load. Maybe 19 | one day we'll circle back on this and make a special image to display in this 20 | case, but for now that will do. 21 | -------------------------------------------------------------------------------- /exercises/04.image/02.problem.error/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/04.image/02.problem.error/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | const imgCache = new Map>() 25 | 26 | export function imgSrc(src: string) { 27 | const imgPromise = imgCache.get(src) ?? preloadImage(src) 28 | imgCache.set(src, imgPromise) 29 | return imgPromise 30 | } 31 | 32 | function preloadImage(src: string) { 33 | return new Promise(async (resolve, reject) => { 34 | const img = new Image() 35 | img.src = src 36 | img.onload = () => resolve(src) 37 | img.onerror = reject 38 | }) 39 | } 40 | 41 | // added the version to prevent caching to make testing easier 42 | const version = Date.now() 43 | 44 | export function getImageUrlForShip( 45 | shipName: string, 46 | { size }: { size: number }, 47 | ) { 48 | // return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}&version=${version}` 49 | // 🧝‍♂️ This is just here for us to test what happens when the image fails to load 50 | const intentionalTypoUrl = `/img/typo/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}&version=${version}` 51 | return intentionalTypoUrl 52 | } 53 | -------------------------------------------------------------------------------- /exercises/04.image/02.solution.error/README.mdx: -------------------------------------------------------------------------------- 1 | # Img Error Boundary 2 | 3 | 4 | 5 | 👨‍💼 Great! Hopefully our users don't encounter this much, but at least when they 6 | do we'll provide as useful information as possible. 7 | 8 | Additionally, we could definitely come up with a better fallback. I don't want 9 | you walking away from this thinking that rendering a broken image is the best 10 | approach to this. But it's a start! 11 | -------------------------------------------------------------------------------- /exercises/04.image/02.solution.error/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/04.image/02.solution.error/img-error.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { waitFor } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('Error boundary fallback is used for image', async () => { 7 | await waitFor( 8 | () => { 9 | const img = Array.from(document.querySelectorAll('img')).find((img) => 10 | img.getAttribute('src')?.includes('typo'), 11 | ) 12 | expect( 13 | img, 14 | '🚨 Image with typo in src should be in the document', 15 | ).toBeTruthy() 16 | }, 17 | { timeout: 3000 }, 18 | ) 19 | }) 20 | -------------------------------------------------------------------------------- /exercises/04.image/02.solution.error/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | const imgCache = new Map>() 25 | 26 | export function imgSrc(src: string) { 27 | const imgPromise = imgCache.get(src) ?? preloadImage(src) 28 | imgCache.set(src, imgPromise) 29 | return imgPromise 30 | } 31 | 32 | function preloadImage(src: string) { 33 | return new Promise(async (resolve, reject) => { 34 | const img = new Image() 35 | img.src = src 36 | img.onload = () => resolve(src) 37 | img.onerror = reject 38 | }) 39 | } 40 | 41 | // added the version to prevent caching to make testing easier 42 | const version = Date.now() 43 | 44 | export function getImageUrlForShip( 45 | shipName: string, 46 | { size }: { size: number }, 47 | ) { 48 | // return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}&version=${version}` 49 | // 🧝‍♂️ This is just here for us to test what happens when the image fails to load 50 | const intentionalTypoUrl = `/img/typo/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}&version=${version}` 51 | return intentionalTypoUrl 52 | } 53 | -------------------------------------------------------------------------------- /exercises/04.image/03.problem.key/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/04.image/03.problem.key/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | 5 | * { 6 | box-sizing: border-box; 7 | } 8 | 9 | .app-wrapper { 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | justify-content: center; 14 | height: 100vh; 15 | } 16 | 17 | .ship-buttons { 18 | display: flex; 19 | justify-content: space-between; 20 | width: 300px; 21 | padding-bottom: 10px; 22 | } 23 | 24 | .ship-buttons button { 25 | border-radius: 2px; 26 | padding: 2px 4px; 27 | font-size: 0.75rem; 28 | background-color: white; 29 | color: black; 30 | &:not(.active) { 31 | box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.5); 32 | } 33 | &.active { 34 | box-shadow: inset 0 0 4px 0 rgba(0, 0, 0, 0.5); 35 | } 36 | } 37 | 38 | .app { 39 | display: flex; 40 | max-width: 1024px; 41 | border: 1px solid #000; 42 | border-start-end-radius: 0.5rem; 43 | border-start-start-radius: 0.5rem; 44 | border-end-start-radius: 50% 8%; 45 | border-end-end-radius: 50% 8%; 46 | overflow: hidden; 47 | } 48 | 49 | .search { 50 | width: 150px; 51 | max-height: 400px; 52 | overflow: hidden; 53 | display: flex; 54 | flex-direction: column; 55 | 56 | input { 57 | width: 100%; 58 | border: 0; 59 | border-bottom: 1px solid #000; 60 | padding: 8px; 61 | line-height: 1.5; 62 | border-top-left-radius: 0.5rem; 63 | } 64 | 65 | ul { 66 | flex: 1; 67 | list-style: none; 68 | padding: 4px; 69 | padding-bottom: 30px; 70 | margin: 0; 71 | display: flex; 72 | flex-direction: column; 73 | gap: 8px; 74 | overflow-y: auto; 75 | li { 76 | button { 77 | display: flex; 78 | align-items: center; 79 | gap: 4px; 80 | border: none; 81 | background-color: transparent; 82 | &:hover { 83 | text-decoration: underline; 84 | } 85 | img { 86 | width: 20px; 87 | height: 20px; 88 | object-fit: contain; 89 | border-radius: 50%; 90 | } 91 | } 92 | } 93 | } 94 | } 95 | 96 | .details { 97 | flex: 1; 98 | height: 400px; 99 | position: relative; 100 | overflow: hidden; 101 | } 102 | 103 | .ship-info { 104 | height: 100%; 105 | width: 300px; 106 | margin: auto; 107 | overflow: auto; 108 | background-color: #eee; 109 | border-radius: 4px; 110 | padding: 20px; 111 | position: relative; 112 | } 113 | 114 | .ship-info.ship-loading { 115 | opacity: 0.6; 116 | } 117 | 118 | .ship-info h2 { 119 | font-weight: bold; 120 | text-align: center; 121 | margin-top: 0.3em; 122 | } 123 | 124 | .ship-info img { 125 | width: 100%; 126 | height: 100%; 127 | aspect-ratio: 1; 128 | object-fit: contain; 129 | } 130 | 131 | .ship-info .ship-info__img-wrapper { 132 | margin-top: 20px; 133 | width: 100%; 134 | height: 200px; 135 | } 136 | 137 | .ship-info .ship-info__fetch-time { 138 | position: absolute; 139 | top: 6px; 140 | right: 10px; 141 | } 142 | 143 | .app-error { 144 | position: relative; 145 | background-image: url('/img/broken-ship.webp'); 146 | background-size: contain; 147 | background-repeat: no-repeat; 148 | background-position: center; 149 | width: 400px; 150 | height: 400px; 151 | p { 152 | position: absolute; 153 | top: 30%; 154 | left: 50%; 155 | transform: translate(-50%, -50%); 156 | background-color: white; 157 | padding: 6px 12px; 158 | border-radius: 1rem; 159 | font-size: 1.5rem; 160 | font-weight: bold; 161 | width: 300px; 162 | text-align: center; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /exercises/04.image/03.problem.key/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | const imgCache = new Map>() 25 | 26 | export function imgSrc(src: string) { 27 | const imgPromise = imgCache.get(src) ?? preloadImage(src) 28 | imgCache.set(src, imgPromise) 29 | return imgPromise 30 | } 31 | 32 | function preloadImage(src: string) { 33 | return new Promise(async (resolve, reject) => { 34 | const img = new Image() 35 | img.src = src 36 | img.onload = () => resolve(src) 37 | img.onerror = reject 38 | }) 39 | } 40 | 41 | // added the version to prevent caching to make testing easier 42 | const version = Date.now() 43 | 44 | export function getImageUrlForShip( 45 | shipName: string, 46 | { size }: { size: number }, 47 | ) { 48 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}&version=${version}` 49 | } 50 | -------------------------------------------------------------------------------- /exercises/04.image/03.solution.key/README.mdx: -------------------------------------------------------------------------------- 1 | # Key prop 2 | 3 | 4 | 5 | 👨‍💼 Amazing work! We now have exactly the kind of experience we want for this 6 | page by not forcing the data to wait for the image, but also allowing the image 7 | to be displayed only when it's ready. 8 | -------------------------------------------------------------------------------- /exercises/04.image/03.solution.key/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/04.image/03.solution.key/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | 5 | * { 6 | box-sizing: border-box; 7 | } 8 | 9 | .app-wrapper { 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | justify-content: center; 14 | height: 100vh; 15 | } 16 | 17 | .ship-buttons { 18 | display: flex; 19 | justify-content: space-between; 20 | width: 300px; 21 | padding-bottom: 10px; 22 | } 23 | 24 | .ship-buttons button { 25 | border-radius: 2px; 26 | padding: 2px 4px; 27 | font-size: 0.75rem; 28 | background-color: white; 29 | color: black; 30 | &:not(.active) { 31 | box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.5); 32 | } 33 | &.active { 34 | box-shadow: inset 0 0 4px 0 rgba(0, 0, 0, 0.5); 35 | } 36 | } 37 | 38 | .app { 39 | display: flex; 40 | max-width: 1024px; 41 | border: 1px solid #000; 42 | border-start-end-radius: 0.5rem; 43 | border-start-start-radius: 0.5rem; 44 | border-end-start-radius: 50% 8%; 45 | border-end-end-radius: 50% 8%; 46 | overflow: hidden; 47 | } 48 | 49 | .search { 50 | width: 150px; 51 | max-height: 400px; 52 | overflow: hidden; 53 | display: flex; 54 | flex-direction: column; 55 | 56 | input { 57 | width: 100%; 58 | border: 0; 59 | border-bottom: 1px solid #000; 60 | padding: 8px; 61 | line-height: 1.5; 62 | border-top-left-radius: 0.5rem; 63 | } 64 | 65 | ul { 66 | flex: 1; 67 | list-style: none; 68 | padding: 4px; 69 | padding-bottom: 30px; 70 | margin: 0; 71 | display: flex; 72 | flex-direction: column; 73 | gap: 8px; 74 | overflow-y: auto; 75 | li { 76 | button { 77 | display: flex; 78 | align-items: center; 79 | gap: 4px; 80 | border: none; 81 | background-color: transparent; 82 | &:hover { 83 | text-decoration: underline; 84 | } 85 | img { 86 | width: 20px; 87 | height: 20px; 88 | object-fit: contain; 89 | border-radius: 50%; 90 | } 91 | } 92 | } 93 | } 94 | } 95 | 96 | .details { 97 | flex: 1; 98 | height: 400px; 99 | position: relative; 100 | overflow: hidden; 101 | } 102 | 103 | .ship-info { 104 | height: 100%; 105 | width: 300px; 106 | margin: auto; 107 | overflow: auto; 108 | background-color: #eee; 109 | border-radius: 4px; 110 | padding: 20px; 111 | position: relative; 112 | } 113 | 114 | .ship-info.ship-loading { 115 | opacity: 0.6; 116 | } 117 | 118 | .ship-info h2 { 119 | font-weight: bold; 120 | text-align: center; 121 | margin-top: 0.3em; 122 | } 123 | 124 | .ship-info img { 125 | width: 100%; 126 | height: 100%; 127 | aspect-ratio: 1; 128 | object-fit: contain; 129 | } 130 | 131 | .ship-info .ship-info__img-wrapper { 132 | margin-top: 20px; 133 | width: 100%; 134 | height: 200px; 135 | } 136 | 137 | .ship-info .ship-info__fetch-time { 138 | position: absolute; 139 | top: 6px; 140 | right: 10px; 141 | } 142 | 143 | .app-error { 144 | position: relative; 145 | background-image: url('/img/broken-ship.webp'); 146 | background-size: contain; 147 | background-repeat: no-repeat; 148 | background-position: center; 149 | width: 400px; 150 | height: 400px; 151 | p { 152 | position: absolute; 153 | top: 30%; 154 | left: 50%; 155 | transform: translate(-50%, -50%); 156 | background-color: white; 157 | padding: 6px 12px; 158 | border-radius: 1rem; 159 | font-size: 1.5rem; 160 | font-weight: bold; 161 | width: 300px; 162 | text-align: center; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /exercises/04.image/03.solution.key/suspense-key.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, waitFor, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('Initial ship details are loaded', async () => { 7 | await screen.findByRole('heading', { name: /Dreadnought/i }) 8 | await waitFor( 9 | () => expect(screen.queryAllByText('loading')).toHaveLength(0), 10 | { timeout: 3000 }, 11 | ) 12 | await new Promise((resolve) => { 13 | screen 14 | .getByAltText('Dreadnought') 15 | .addEventListener('load', resolve, { once: true }) 16 | }) 17 | await new Promise((resolve) => setTimeout(resolve, 250)) 18 | }) 19 | 20 | await testStep('Click on a different ship', async () => { 21 | fireEvent.click(screen.getByRole('button', { name: 'Interceptor' })) 22 | }) 23 | 24 | await testStep('The suspense boundary should be shown', async () => { 25 | await waitFor( 26 | () => { 27 | const fallbackImg = screen.getByAltText('Interceptor') 28 | expect( 29 | fallbackImg.getAttribute('src'), 30 | '🚨 The fallback image is not shown. Make sure you add a key to the Suspense boundary.', 31 | ).toBe('/img/fallback-ship.png') 32 | }, 33 | { timeout: 3000 }, 34 | ) 35 | }) 36 | -------------------------------------------------------------------------------- /exercises/04.image/03.solution.key/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship } from './api.server.ts' 2 | 3 | export type { Ship } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | const imgCache = new Map>() 25 | 26 | export function imgSrc(src: string) { 27 | const imgPromise = imgCache.get(src) ?? preloadImage(src) 28 | imgCache.set(src, imgPromise) 29 | return imgPromise 30 | } 31 | 32 | function preloadImage(src: string) { 33 | return new Promise(async (resolve, reject) => { 34 | const img = new Image() 35 | img.src = src 36 | img.onload = () => resolve(src) 37 | img.onerror = reject 38 | }) 39 | } 40 | 41 | // added the version to prevent caching to make testing easier 42 | const version = Date.now() 43 | 44 | export function getImageUrlForShip( 45 | shipName: string, 46 | { size }: { size: number }, 47 | ) { 48 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}&version=${version}` 49 | } 50 | -------------------------------------------------------------------------------- /exercises/04.image/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Suspense img 2 | 3 | 4 | 5 | 👨‍💼 Great job on making this suspending image component 👏👏 6 | -------------------------------------------------------------------------------- /exercises/05.responsive/01.problem.deferred/README.mdx: -------------------------------------------------------------------------------- 1 | # useDeferredValue 2 | 3 | 4 | 5 | 🧝‍♂️ I've made a number of changes (check my work) 6 | because we want people to be able to search through a list of ships and select 7 | the ones they're most interested in. 8 | 9 | 👨‍💼 Thanks Kellie! So, here's the thing, we have a search endpoint for the filter 10 | on the left side, and Kellie applied the same pattern for handling that async 11 | interaction as we did with the ship details, including the `useTransition` for 12 | showing a pending UI. 13 | 14 | But the problem is, during the transition, the input isn't responsive to user 15 | input. It's really annoying to use as a result. We need the UI to be responsive. 16 | 17 | So could you please remove the transition and switch to `useDeferredValue` 18 | instead? Make sure to keep the pending UI experience, we just want the user to 19 | be able to interrupt the pending state by typing more into the input. 20 | 21 | 🦉 Something you might try in this exercise is adding a `console.log` of the 22 | `search` and the `deferredSearch` and see how React renders your component twice 23 | when you type in the input (once with the old value and once with the new 24 | value). You'll actually see quite a few render calls as you type, but this 25 | should be instructive. If you wish, you might add a delay argument to the 26 | `searchShips` call (`searchShips(search, 1000)`) to simulate a slow network 27 | response to more easily see which sets of logs are associated with which 28 | renders. 29 | -------------------------------------------------------------------------------- /exercises/05.responsive/01.problem.deferred/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/05.responsive/01.problem.deferred/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship, type ShipSearch } from './api.server.ts' 2 | 3 | export type { Ship, ShipSearch } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | const shipSearchCache = new Map>() 25 | 26 | export function searchShips(query: string, delay?: number) { 27 | const searchPromise = 28 | shipSearchCache.get(query) ?? searchShipImpl(query, delay) 29 | shipSearchCache.set(query, searchPromise) 30 | return searchPromise 31 | } 32 | 33 | async function searchShipImpl(query: string, delay?: number) { 34 | const searchParams = new URLSearchParams({ query }) 35 | if (delay) searchParams.set('delay', String(delay)) 36 | const response = await fetch(`api/search-ships?${searchParams.toString()}`) 37 | if (!response.ok) { 38 | return Promise.reject(new Error(await response.text())) 39 | } 40 | const ship = await response.json() 41 | return ship as ShipSearch 42 | } 43 | 44 | const imgCache = new Map>() 45 | 46 | export function imgSrc(src: string) { 47 | const imgPromise = imgCache.get(src) ?? preloadImage(src) 48 | imgCache.set(src, imgPromise) 49 | return imgPromise 50 | } 51 | 52 | function preloadImage(src: string) { 53 | return new Promise(async (resolve, reject) => { 54 | const img = new Image() 55 | img.src = src 56 | img.onload = () => resolve(src) 57 | img.onerror = reject 58 | }) 59 | } 60 | 61 | export function getImageUrlForShip( 62 | shipName: string, 63 | { size }: { size: number }, 64 | ) { 65 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 66 | } 67 | -------------------------------------------------------------------------------- /exercises/05.responsive/01.solution.deferred/README.mdx: -------------------------------------------------------------------------------- 1 | # useDeferredValue 2 | 3 | 4 | 5 | 👨‍💼 Great job on this! This really improved (saved) the UX! 6 | -------------------------------------------------------------------------------- /exercises/05.responsive/01.solution.deferred/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/05.responsive/01.solution.deferred/responsive-search.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, waitFor, fireEvent, within } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('Initial render', async () => { 7 | await screen.findByRole('heading', { name: /Dreadnought/i }) 8 | await screen.findByPlaceholderText(/filter ships/i) 9 | }) 10 | 11 | await testStep('Search input is responsive during search', async () => { 12 | const searchInput = screen.getByPlaceholderText(/filter ships/i) 13 | 14 | // Type slowly to simulate user input 15 | fireEvent.change(searchInput, { target: { value: 'S' } }) 16 | await new Promise((resolve) => setTimeout(resolve, 100)) 17 | fireEvent.change(searchInput, { target: { value: 'St' } }) 18 | await new Promise((resolve) => setTimeout(resolve, 100)) 19 | fireEvent.change(searchInput, { target: { value: 'Sta' } }) 20 | 21 | // Check if the input value updates basically immediately 22 | await new Promise((resolve) => setTimeout(resolve, 0)) 23 | expect(searchInput).toHaveValue('Sta') 24 | 25 | // Wait for the search results to update 26 | await waitFor( 27 | () => { 28 | expect(screen.getByText(/Star Hopper/i)).toBeInTheDocument() 29 | expect(screen.getByText(/Star Destroyer/i)).toBeInTheDocument() 30 | }, 31 | { timeout: 2000 }, 32 | ) 33 | }) 34 | 35 | await testStep('Pending UI is shown during search', async () => { 36 | const searchInput = screen.getByPlaceholderText(/filter ships/i) 37 | 38 | fireEvent.change(searchInput, { target: { value: 'Infinity' } }) 39 | 40 | const resultsContainer = within(document.querySelector('.search')!).getByRole( 41 | 'list', 42 | ) 43 | 44 | // Check for pending UI (lowered opacity) 45 | await waitFor(() => { 46 | expect(resultsContainer).toHaveStyle({ opacity: '0.6' }) 47 | }) 48 | 49 | // Wait for the search results to update 50 | await waitFor( 51 | () => { 52 | expect(screen.getByText(/Infinity Drifter/i)).toBeInTheDocument() 53 | expect(resultsContainer).toHaveStyle({ opacity: '1' }) 54 | }, 55 | { timeout: 2000 }, 56 | ) 57 | }) 58 | 59 | await testStep('User can interrupt pending state', async () => { 60 | const searchInput = screen.getByPlaceholderText(/filter ships/i) 61 | 62 | fireEvent.change(searchInput, { target: { value: 'Gal' } }) 63 | await new Promise((resolve) => setTimeout(resolve, 100)) 64 | fireEvent.change(searchInput, { target: { value: 'Pla' } }) 65 | 66 | // Check if the input value updates immediately 67 | expect(searchInput).toHaveValue('Pla') 68 | 69 | // Wait for the search results to update to the latest input 70 | await waitFor( 71 | () => { 72 | expect(screen.getByText(/Planet Hopper/i)).toBeInTheDocument() 73 | }, 74 | { timeout: 2000 }, 75 | ) 76 | }) 77 | -------------------------------------------------------------------------------- /exercises/05.responsive/01.solution.deferred/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship, type ShipSearch } from './api.server.ts' 2 | 3 | export type { Ship, ShipSearch } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | const shipSearchCache = new Map>() 25 | 26 | export function searchShips(query: string, delay?: number) { 27 | const searchPromise = 28 | shipSearchCache.get(query) ?? searchShipImpl(query, delay) 29 | shipSearchCache.set(query, searchPromise) 30 | return searchPromise 31 | } 32 | 33 | async function searchShipImpl(query: string, delay?: number) { 34 | const searchParams = new URLSearchParams({ query }) 35 | if (delay) searchParams.set('delay', String(delay)) 36 | const response = await fetch(`api/search-ships?${searchParams.toString()}`) 37 | if (!response.ok) { 38 | return Promise.reject(new Error(await response.text())) 39 | } 40 | const ship = await response.json() 41 | return ship as ShipSearch 42 | } 43 | 44 | const imgCache = new Map>() 45 | 46 | export function imgSrc(src: string) { 47 | const imgPromise = imgCache.get(src) ?? preloadImage(src) 48 | imgCache.set(src, imgPromise) 49 | return imgPromise 50 | } 51 | 52 | function preloadImage(src: string) { 53 | return new Promise(async (resolve, reject) => { 54 | const img = new Image() 55 | img.src = src 56 | img.onload = () => resolve(src) 57 | img.onerror = reject 58 | }) 59 | } 60 | 61 | export function getImageUrlForShip( 62 | shipName: string, 63 | { size }: { size: number }, 64 | ) { 65 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}` 66 | } 67 | -------------------------------------------------------------------------------- /exercises/05.responsive/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Responsive 2 | 3 | 4 | 5 | 👨‍💼 You now know how to use `useDeferredValue` to give us the benefits of a 6 | responsive UI when there's a deferred value. 7 | 8 | 🦉 If you want to understand a little more about how `useDeferredValue` differs 9 | from `useTransition`, then try to replace the `useTransition` in the previous 10 | example with `useDeferredValue`. You'll notice we no longer get a "transition" 11 | where we preserve the previous UI while the new UI is loading and instead we 12 | go straight to fallbacks. 13 | 14 | Different solutions for different use cases. 15 | -------------------------------------------------------------------------------- /exercises/05.responsive/README.mdx: -------------------------------------------------------------------------------- 1 | # Responsive 2 | 3 | 4 | 5 | Something you'll remember from previous exercises is that when you suspend with 6 | a `useTransition`, React hangs on to the previous state and gives you a pending 7 | boolean so you can display the pending state. The problem with this is if you 8 | want to display the state that triggered the transition. For example, if the 9 | user is typing in a search box which you're controlling with state, you want to 10 | keep the ``'s `value` up-to-date with what they're typing, not with 11 | the previous state while React is waiting for the transition to complete. 12 | 13 | So we need a way that allows us to both display some pending state while also 14 | allowing us to display the state that triggered a component to suspend. This is 15 | where `useDeferredValue` comes in: 16 | 17 | ```tsx 18 | const deferredValue = useDeferredValue(value) 19 | const isPending = deferredValue !== value 20 | ``` 21 | 22 | `useDeferredValue` makes React do something kind of funny. It makes React render 23 | your component twice. Once with the `deferredValue` set to the previous `value` 24 | and second with the `deferredValue` set to the current `value`. This allows 25 | React to handle components that suspend and you can know whether to display 26 | pending UI based on whether the `deferredValue` and `value` differ. 27 | 28 | The React docs do a good job explaining how this works, so 29 | [📜 check the `useDeferredValue` docs](https://react.dev/reference/react/useDeferredValue) 30 | for details. 31 | 32 | This may feel pretty similar to `useTransition`. Both give you the ability to 33 | handle pending UI for suspending components. `useDeferredValue` is what you'll 34 | use more often for typical user interactions. `useTransition` will normally be 35 | handled when the user is navigating or refreshing a whole UI. 36 | 37 | It should be noted that this can also be used to keep things snappy if you have 38 | a component that's particularly slow. You can pass the `deferredValue` to the 39 | slow component and the rest of the application will be highly responsive to user 40 | interaction. The slow component will only update when it manages to finish 41 | rendering with the latest `deferredValue`. This works because the background 42 | renders can be thrown away whenever the `deferredValue` changes. 43 | -------------------------------------------------------------------------------- /exercises/06.optimization/01.problem.parallel/README.mdx: -------------------------------------------------------------------------------- 1 | # Parallel Loading 2 | 3 | 4 | 5 | 6 | The video was recorded with a beta version of React 19 and the behavior you 7 | see in the video was fixed in the final release of React 19. Feel free to skip 8 | this step if you'd like. Learn more from [issue #98 on the workshop 9 | repo](https://github.com/epicweb-dev/react-suspense/issues/98). 10 | 11 | 12 | 👨‍💼 Right now our `ShipDetails` has to wait for the ship's data before we render 13 | the `ShipImg` component which will then start loading the image. However, we 14 | can start loading the image as soon as we have the ship's name. 15 | 16 | Please preload the image for the ship in `ShipDetails` so we don't have that 17 | waterfall. 18 | 19 | Pull up the Network tab of your DevTools to and click a ship. You'll notice that 20 | before the image doesn't start loading until the ship's data is loaded and after 21 | the image starts loading as soon as you select a ship. 22 | -------------------------------------------------------------------------------- /exercises/06.optimization/01.problem.parallel/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/06.optimization/01.problem.parallel/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship, type ShipSearch } from './api.server.ts' 2 | 3 | export type { Ship, ShipSearch } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | const shipSearchCache = new Map>() 25 | 26 | export function searchShips(query: string, delay?: number) { 27 | const searchPromise = 28 | shipSearchCache.get(query) ?? searchShipImpl(query, delay) 29 | shipSearchCache.set(query, searchPromise) 30 | return searchPromise 31 | } 32 | 33 | async function searchShipImpl(query: string, delay?: number) { 34 | const searchParams = new URLSearchParams({ query }) 35 | if (delay) searchParams.set('delay', String(delay)) 36 | const response = await fetch(`api/search-ships?${searchParams.toString()}`) 37 | if (!response.ok) { 38 | return Promise.reject(new Error(await response.text())) 39 | } 40 | const ship = await response.json() 41 | return ship as ShipSearch 42 | } 43 | 44 | const imgCache = new Map>() 45 | 46 | export function imgSrc(src: string) { 47 | const imgPromise = imgCache.get(src) ?? preloadImage(src) 48 | imgCache.set(src, imgPromise) 49 | return imgPromise 50 | } 51 | 52 | function preloadImage(src: string) { 53 | return new Promise(async (resolve, reject) => { 54 | const img = new Image() 55 | img.src = src 56 | img.onload = () => resolve(src) 57 | img.onerror = reject 58 | }) 59 | } 60 | 61 | // added the version to prevent caching to make testing easier 62 | const version = Date.now() 63 | 64 | export function getImageUrlForShip( 65 | shipName: string, 66 | { size }: { size: number }, 67 | ) { 68 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}&version=${version}` 69 | } 70 | -------------------------------------------------------------------------------- /exercises/06.optimization/01.solution.parallel/README.mdx: -------------------------------------------------------------------------------- 1 | # Parallel Loading 2 | 3 | 4 | 5 | 👨‍💼 Boy, this sure would be annoying to have to worry about everywhere in our app 6 | where we're loading data. Hopefully we can adopt [Remix](https://remix.run) in 7 | the future so we don't have to worry about this 😅 8 | -------------------------------------------------------------------------------- /exercises/06.optimization/01.solution.parallel/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | }, 21 | }) 22 | } 23 | case 'get-ship': { 24 | const result = await getShip(request) 25 | return new Response(JSON.stringify(result), { 26 | headers: { 27 | 'content-type': 'application/json', 28 | }, 29 | }) 30 | } 31 | default: { 32 | return new Response('Not found', { status: 404 }) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /exercises/06.optimization/01.solution.parallel/parallel-loading.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep, dtl } from '@epic-web/workshop-utils/test' 2 | const { screen, waitFor, fireEvent } = dtl 3 | 4 | import './index.tsx' 5 | 6 | await testStep('Initial render', async () => { 7 | await screen.findByAltText(/dreadnought/i) 8 | await waitFor(() => expect(screen.queryAllByText('loading')).toHaveLength(0)) 9 | }) 10 | 11 | let imageLoadStartTime: number | null = null 12 | let dataFetchStartTime: number | null = null 13 | 14 | function isPerformanceResourceTiming( 15 | entry: PerformanceEntry, 16 | ): entry is PerformanceResourceTiming { 17 | return entry.entryType === 'resource' 18 | } 19 | 20 | const observer = new PerformanceObserver((list) => { 21 | for (const entry of list.getEntries()) { 22 | if (isPerformanceResourceTiming(entry)) { 23 | if ( 24 | entry.initiatorType === 'img' && 25 | entry.name.includes('infinity') && 26 | entry.name.includes('size=200') 27 | ) { 28 | imageLoadStartTime = entry.startTime 29 | } else if (entry.name.includes('api/get-ship')) { 30 | dataFetchStartTime = entry.startTime 31 | } 32 | } 33 | } 34 | }) 35 | 36 | observer.observe({ type: 'resource' }) 37 | 38 | await testStep('click on a ship', async () => { 39 | await screen.findByAltText(/infinity/i) 40 | fireEvent.click(screen.getByText(/infinity/i)) 41 | }) 42 | 43 | await testStep('Verify parallel loading', async () => { 44 | await waitFor(() => { 45 | expect( 46 | imageLoadStartTime, 47 | '🚨 Image load start time should be recorded', 48 | ).not.toBeNull() 49 | expect( 50 | dataFetchStartTime, 51 | '🚨 Data fetch start time should be recorded', 52 | ).not.toBeNull() 53 | }) 54 | 55 | if (imageLoadStartTime && dataFetchStartTime) { 56 | const timeDifference = Math.abs(imageLoadStartTime - dataFetchStartTime) 57 | 58 | expect( 59 | timeDifference, 60 | '🚨 Image and data fetch should start within 10ms of each other', 61 | ).toBeLessThan(10) 62 | } 63 | }) 64 | -------------------------------------------------------------------------------- /exercises/06.optimization/01.solution.parallel/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship, type ShipSearch } from './api.server.ts' 2 | 3 | export type { Ship, ShipSearch } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | const shipSearchCache = new Map>() 25 | 26 | export function searchShips(query: string, delay?: number) { 27 | const searchPromise = 28 | shipSearchCache.get(query) ?? searchShipImpl(query, delay) 29 | shipSearchCache.set(query, searchPromise) 30 | return searchPromise 31 | } 32 | 33 | async function searchShipImpl(query: string, delay?: number) { 34 | const searchParams = new URLSearchParams({ query }) 35 | if (delay) searchParams.set('delay', String(delay)) 36 | const response = await fetch(`api/search-ships?${searchParams.toString()}`) 37 | if (!response.ok) { 38 | return Promise.reject(new Error(await response.text())) 39 | } 40 | const ship = await response.json() 41 | return ship as ShipSearch 42 | } 43 | 44 | const imgCache = new Map>() 45 | 46 | export function imgSrc(src: string) { 47 | const imgPromise = imgCache.get(src) ?? preloadImage(src) 48 | imgCache.set(src, imgPromise) 49 | return imgPromise 50 | } 51 | 52 | function preloadImage(src: string) { 53 | return new Promise(async (resolve, reject) => { 54 | const img = new Image() 55 | img.src = src 56 | img.onload = () => resolve(src) 57 | img.onerror = reject 58 | }) 59 | } 60 | 61 | // added the version to prevent caching to make testing easier 62 | const version = Date.now() 63 | 64 | export function getImageUrlForShip( 65 | shipName: string, 66 | { size }: { size: number }, 67 | ) { 68 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}&version=${version}` 69 | } 70 | -------------------------------------------------------------------------------- /exercises/06.optimization/02.problem.cache/README.mdx: -------------------------------------------------------------------------------- 1 | # Server Cache 2 | 3 | 4 | 5 | 👨‍💼 As the user selects different ships, we cache the promise in our `getShip` 6 | utility. But if they refresh the page, the cache is lost. Our ship data doesn't 7 | change much so we could have the user's browser cache the ship details for a 8 | while to make the data persist across page refreshes. 9 | 10 | Please add a `Cache-Control` header to 11 | for both our API endpoints. 12 | -------------------------------------------------------------------------------- /exercises/06.optimization/02.problem.cache/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | // 🐨 add a cache-control header with a max-age=300 21 | // to cache this response for 300 seconds (5 minutes) 22 | }, 23 | }) 24 | } 25 | case 'get-ship': { 26 | const result = await getShip(request) 27 | return new Response(JSON.stringify(result), { 28 | headers: { 29 | 'content-type': 'application/json', 30 | // 🐨 add a cache-control header with a max-age=300 31 | // to cache this response for 300 seconds (5 minutes) 32 | }, 33 | }) 34 | } 35 | default: { 36 | return new Response('Not found', { status: 404 }) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /exercises/06.optimization/02.problem.cache/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship, type ShipSearch } from './api.server.ts' 2 | 3 | export type { Ship, ShipSearch } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | const shipSearchCache = new Map>() 25 | 26 | export function searchShips(query: string, delay?: number) { 27 | const searchPromise = 28 | shipSearchCache.get(query) ?? searchShipImpl(query, delay) 29 | shipSearchCache.set(query, searchPromise) 30 | return searchPromise 31 | } 32 | 33 | async function searchShipImpl(query: string, delay?: number) { 34 | const searchParams = new URLSearchParams({ query }) 35 | if (delay) searchParams.set('delay', String(delay)) 36 | const response = await fetch(`api/search-ships?${searchParams.toString()}`) 37 | if (!response.ok) { 38 | return Promise.reject(new Error(await response.text())) 39 | } 40 | const ship = await response.json() 41 | return ship as ShipSearch 42 | } 43 | 44 | const imgCache = new Map>() 45 | 46 | export function imgSrc(src: string) { 47 | const imgPromise = imgCache.get(src) ?? preloadImage(src) 48 | imgCache.set(src, imgPromise) 49 | return imgPromise 50 | } 51 | 52 | function preloadImage(src: string) { 53 | return new Promise(async (resolve, reject) => { 54 | const img = new Image() 55 | img.src = src 56 | img.onload = () => resolve(src) 57 | img.onerror = reject 58 | }) 59 | } 60 | 61 | // added the version to prevent caching to make testing easier 62 | const version = Date.now() 63 | 64 | export function getImageUrlForShip( 65 | shipName: string, 66 | { size }: { size: number }, 67 | ) { 68 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}&version=${version}` 69 | } 70 | -------------------------------------------------------------------------------- /exercises/06.optimization/02.solution.cache/README.mdx: -------------------------------------------------------------------------------- 1 | # Server Cache 2 | 3 | 4 | 5 | 👨‍💼 Good work! That's nice! 6 | 7 | 🦉 There's a whole other world of optimizations we can get into when we start 8 | talking about the backend, but for now this is as far as we'll go. We just want 9 | you to know that there are a lot of things you can do to make your server faster 10 | and not all performance problems should be solved on the frontend. 11 | -------------------------------------------------------------------------------- /exercises/06.optimization/02.solution.cache/api.server.ts: -------------------------------------------------------------------------------- 1 | import { getShip, searchShips } from '#shared/ship-api-utils.server' 2 | 3 | export type Ship = Awaited> 4 | export type ShipSearch = Awaited> 5 | 6 | export async function loader({ 7 | request, 8 | params, 9 | }: { 10 | request: Request 11 | params: Record 12 | }) { 13 | const path = params['*'] 14 | switch (path) { 15 | case 'search-ships': { 16 | const result = await searchShips(request) 17 | return new Response(JSON.stringify(result), { 18 | headers: { 19 | 'content-type': 'application/json', 20 | 'cache-control': 'max-age=10', 21 | }, 22 | }) 23 | } 24 | case 'get-ship': { 25 | const result = await getShip(request) 26 | return new Response(JSON.stringify(result), { 27 | headers: { 28 | 'content-type': 'application/json', 29 | 'cache-control': 'max-age=10', 30 | }, 31 | }) 32 | } 33 | default: { 34 | return new Response('Not found', { status: 404 }) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /exercises/06.optimization/02.solution.cache/cache-control.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, testStep } from '@epic-web/workshop-utils/test' 2 | 3 | const searchResult = await testStep( 4 | 'Cache control headers are present for search ships', 5 | async () => { 6 | const response = await fetch('api/search-ships') 7 | const cacheControl = response.headers.get('cache-control') 8 | if (!cacheControl) { 9 | throw new Error( 10 | '🚨 No cache-control header found. Make sure to add a cache-control header with a max-age to the search ships response', 11 | ) 12 | } 13 | expect( 14 | cacheControl, 15 | '🚨 make sure to add a cache-control header with a max-age to the search ships response', 16 | ).toMatch(/max-age=\d+/) 17 | const result = await response.json() 18 | return result as any 19 | }, 20 | ) 21 | 22 | await testStep('Cache control headers are present for get ship', async () => { 23 | const searchParams = new URLSearchParams({ name: searchResult.ships[0].name }) 24 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 25 | const cacheControl = response.headers.get('cache-control') 26 | if (!cacheControl) { 27 | throw new Error( 28 | '🚨 No cache-control header found. Make sure to add a cache-control header with a max-age to the get ship response', 29 | ) 30 | } 31 | expect( 32 | cacheControl, 33 | '🚨 make sure to add a cache-control header with a max-age to the get ship response', 34 | ).toMatch(/max-age=\d+/) 35 | }) 36 | -------------------------------------------------------------------------------- /exercises/06.optimization/02.solution.cache/utils.tsx: -------------------------------------------------------------------------------- 1 | import { type Ship, type ShipSearch } from './api.server.ts' 2 | 3 | export type { Ship, ShipSearch } 4 | 5 | const shipCache = new Map>() 6 | 7 | export function getShip(name: string, delay?: number) { 8 | const shipPromise = shipCache.get(name) ?? getShipImpl(name, delay) 9 | shipCache.set(name, shipPromise) 10 | return shipPromise 11 | } 12 | 13 | async function getShipImpl(name: string, delay?: number) { 14 | const searchParams = new URLSearchParams({ name }) 15 | if (delay) searchParams.set('delay', String(delay)) 16 | const response = await fetch(`api/get-ship?${searchParams.toString()}`) 17 | if (!response.ok) { 18 | return Promise.reject(new Error(await response.text())) 19 | } 20 | const ship = await response.json() 21 | return ship as Ship 22 | } 23 | 24 | const shipSearchCache = new Map>() 25 | 26 | export function searchShips(query: string, delay?: number) { 27 | const searchPromise = 28 | shipSearchCache.get(query) ?? searchShipImpl(query, delay) 29 | shipSearchCache.set(query, searchPromise) 30 | return searchPromise 31 | } 32 | 33 | async function searchShipImpl(query: string, delay?: number) { 34 | const searchParams = new URLSearchParams({ query }) 35 | if (delay) searchParams.set('delay', String(delay)) 36 | const response = await fetch(`api/search-ships?${searchParams.toString()}`) 37 | if (!response.ok) { 38 | return Promise.reject(new Error(await response.text())) 39 | } 40 | const ship = await response.json() 41 | return ship as ShipSearch 42 | } 43 | 44 | const imgCache = new Map>() 45 | 46 | export function imgSrc(src: string) { 47 | const imgPromise = imgCache.get(src) ?? preloadImage(src) 48 | imgCache.set(src, imgPromise) 49 | return imgPromise 50 | } 51 | 52 | function preloadImage(src: string) { 53 | return new Promise(async (resolve, reject) => { 54 | const img = new Image() 55 | img.src = src 56 | img.onload = () => resolve(src) 57 | img.onerror = reject 58 | }) 59 | } 60 | 61 | // added the version to prevent caching to make testing easier 62 | const version = Date.now() 63 | 64 | export function getImageUrlForShip( 65 | shipName: string, 66 | { size }: { size: number }, 67 | ) { 68 | return `/img/ships/${shipName.toLowerCase().replaceAll(' ', '-')}.webp?size=${size}&version=${version}` 69 | } 70 | -------------------------------------------------------------------------------- /exercises/06.optimization/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # Optimizations 2 | 3 | 4 | 5 | 👨‍💼 There's lots to think about with performance optimizations (there's an entire 6 | workshop about that!), but this is a good start. Well done! 7 | -------------------------------------------------------------------------------- /exercises/FINISHED.mdx: -------------------------------------------------------------------------------- 1 | # React Suspense 🔀 2 | 3 | 4 | 5 | 👨‍💼 Congratulations! You've finished the React Suspense workshop! 6 | -------------------------------------------------------------------------------- /exercises/README.mdx: -------------------------------------------------------------------------------- 1 | # React Suspense 🔀 2 | 3 | 4 | 5 | 👨‍💼 Hello, my name is Peter the Product Manager. I'm here to help you get 6 | oriented and to give you your assignments for the workshop! 7 | 8 | We're going to be learning about all things asynchronous with React. In web 9 | applications, there's much you need to concern yourself with when it comes to 10 | asynchronous behavior. Primarily we'll be focusing on data fetching and assets 11 | like images, but you can apply this to any asynchronous behavior in your 12 | application. 13 | 14 | The core of everything we'll be doing revolves around the 15 | [`Suspense`](https://react.dev/reference/react/Suspense) component and the 16 | [`use`](https://react.dev/reference/react/use) hook. The `Suspense` component 17 | enables you to declaratively tell React what to render while waiting for 18 | something to load. The `use` hook enables you to convert something async (a 19 | promise) into the resolved value. 20 | 21 | And of course, there's `ErrorBoundary` which is a component that you can use to 22 | catch errors in your application and display a fallback UI. We'll be following 23 | the React team's recommendation and using 24 | [`react-error-boundary`](https://npm.im/react-error-boundary) for this. 25 | 26 | Under the hood, React will make this work and you'll learn all about how in this 27 | workshop. 28 | 29 | Let's get going! 30 | 31 | 🎵 Check out the workshop theme song! 🎶 32 | 33 | 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-suspense", 3 | "private": true, 4 | "epicshop": { 5 | "title": "React Suspense 🔀", 6 | "subtitle": "Simplify your Async UI and improve your User Experience", 7 | "githubRepo": "https://github.com/epicweb-dev/react-suspense", 8 | "stackBlitzConfig": { 9 | "view": "editor" 10 | }, 11 | "product": { 12 | "host": "www.epicreact.dev", 13 | "slug": "react-suspense", 14 | "displayName": "EpicReact.dev", 15 | "displayNameShort": "Epic React", 16 | "logo": "/logo.svg", 17 | "discordChannelId": "1285244676286189569", 18 | "discordTags": [ 19 | "1285246046498328627", 20 | "1285245867791745116" 21 | ] 22 | }, 23 | "onboardingVideo": "https://www.epicweb.dev/tips/get-started-with-the-epic-workshop-app-for-react", 24 | "instructor": { 25 | "name": "Kent C. Dodds", 26 | "avatar": "/images/instructor.png", 27 | "𝕏": "kentcdodds" 28 | } 29 | }, 30 | "type": "module", 31 | "imports": { 32 | "#*": "./*" 33 | }, 34 | "scripts": { 35 | "postinstall": "cd ./epicshop && npm install", 36 | "start": "npx --prefix ./epicshop epicshop start", 37 | "dev": "npx --prefix ./epicshop epicshop start", 38 | "setup": "node ./epicshop/setup.js", 39 | "setup:custom": "node ./epicshop/setup-custom.js", 40 | "lint": "eslint .", 41 | "format": "prettier --write .", 42 | "typecheck": "tsc -b" 43 | }, 44 | "keywords": [], 45 | "author": "Kent C. Dodds (https://kentcdodds.com/)", 46 | "license": "GPL-3.0-only", 47 | "dependencies": { 48 | "@epic-web/config": "^1.16.3", 49 | "@epic-web/invariant": "^1.0.0", 50 | "@epic-web/workshop-utils": "^5.21.0", 51 | "react": "^19.0.0", 52 | "react-dom": "^19.0.0", 53 | "react-error-boundary": "^4.1.2", 54 | "spin-delay": "^2.0.1" 55 | }, 56 | "devDependencies": { 57 | "@types/node": "^22.10.1", 58 | "@types/react": "^19.0.0", 59 | "@types/react-dom": "^19.0.0", 60 | "eslint": "^9.16.0", 61 | "npm-run-all": "^4.1.5", 62 | "prettier": "^3.4.2", 63 | "typescript": "^5.7.2" 64 | }, 65 | "workspaces": [ 66 | "./exercises/**/*" 67 | ], 68 | "engines": { 69 | "node": ">=20", 70 | "npm": ">=9.3.0", 71 | "git": ">=2.18.0" 72 | }, 73 | "prettier": "@epic-web/config/prettier", 74 | "prettierIgnore": [ 75 | "node_modules", 76 | "**/build/**", 77 | "**/public/build/**", 78 | ".env", 79 | "**/package.json", 80 | "**/tsconfig.json", 81 | "**/package-lock.json", 82 | "**/playwright-report/**" 83 | ] 84 | } 85 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/images/instructor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/images/instructor.png -------------------------------------------------------------------------------- /public/img/broken-ship.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/broken-ship.webp -------------------------------------------------------------------------------- /public/img/fallback-ship.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/fallback-ship.png -------------------------------------------------------------------------------- /public/img/ships/battleship.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/battleship.webp -------------------------------------------------------------------------------- /public/img/ships/bomber.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/bomber.webp -------------------------------------------------------------------------------- /public/img/ships/cargo-ship.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/cargo-ship.webp -------------------------------------------------------------------------------- /public/img/ships/cruiser.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/cruiser.webp -------------------------------------------------------------------------------- /public/img/ships/diplomatic-vessel.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/diplomatic-vessel.webp -------------------------------------------------------------------------------- /public/img/ships/dreadnought.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/dreadnought.webp -------------------------------------------------------------------------------- /public/img/ships/frigate.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/frigate.webp -------------------------------------------------------------------------------- /public/img/ships/galaxy-cruiser.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/galaxy-cruiser.webp -------------------------------------------------------------------------------- /public/img/ships/gunship.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/gunship.webp -------------------------------------------------------------------------------- /public/img/ships/infinity-drifter.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/infinity-drifter.webp -------------------------------------------------------------------------------- /public/img/ships/interceptor.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/interceptor.webp -------------------------------------------------------------------------------- /public/img/ships/medical-ship.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/medical-ship.webp -------------------------------------------------------------------------------- /public/img/ships/mining-ship.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/mining-ship.webp -------------------------------------------------------------------------------- /public/img/ships/planet-hopper.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/planet-hopper.webp -------------------------------------------------------------------------------- /public/img/ships/research-vessel.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/research-vessel.webp -------------------------------------------------------------------------------- /public/img/ships/scout-ship.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/scout-ship.webp -------------------------------------------------------------------------------- /public/img/ships/space-taxi.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/space-taxi.webp -------------------------------------------------------------------------------- /public/img/ships/star-destroyer.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/star-destroyer.webp -------------------------------------------------------------------------------- /public/img/ships/star-hopper.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/star-hopper.webp -------------------------------------------------------------------------------- /public/img/ships/stealth-cruiser.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/stealth-cruiser.webp -------------------------------------------------------------------------------- /public/img/ships/transport-ship.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/img/ships/transport-ship.webp -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /public/og/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/epicweb-dev/react-suspense/626078efa891c9a92775311d804ab71ae247cd04/public/og/background.png -------------------------------------------------------------------------------- /reset.d.ts: -------------------------------------------------------------------------------- 1 | import '@epic-web/config/reset.d.ts' 2 | -------------------------------------------------------------------------------- /shared/reset.d.ts: -------------------------------------------------------------------------------- 1 | import '@total-typescript/ts-reset' 2 | import '@total-typescript/ts-reset/dom' 3 | 4 | import 'react' 5 | 6 | declare module 'react' { 7 | interface CSSProperties { 8 | [key: `--${string}`]: string | number 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2023"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "module": "ES2022", 9 | "moduleResolution": "Bundler", 10 | "resolveJsonModule": true, 11 | "target": "ES2022", 12 | "strict": true, 13 | "noImplicitAny": true, 14 | "allowJs": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "skipLibCheck": true, 17 | "allowImportingTsExtensions": true, 18 | "noEmit": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts", "**/*.tsx"], 3 | "extends": ["@epic-web/config/typescript"], 4 | "compilerOptions": { 5 | "paths": { 6 | "#shared/*": ["./shared/*"] 7 | } 8 | } 9 | } 10 | --------------------------------------------------------------------------------