diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4a7ea30 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3ded3d0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.woff binary +*.woff2 binary +*.wasm binary diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..251608d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,35 @@ + + +## What + + + +## Why + + + +## How + + + +## Course module + + + +- Module: + +## Checklist + +- [ ] `npm run typecheck` passes +- [ ] `npm run lint` passes (no new warnings) +- [ ] `npm run format:check` passes +- [ ] `npm test` passes +- [ ] PR description explains the **why**, not just the **what** +- [ ] No `console.log`, dead code, or commented-out blocks left behind + +## Screenshots / recordings + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..749ba8d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Lint / Typecheck / Build + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: ['20.19.x', '22.12.x'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Format check + run: npm run format:check + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml new file mode 100644 index 0000000..16f3612 --- /dev/null +++ b/.github/workflows/deploy-preview.yml @@ -0,0 +1,38 @@ +name: Deploy Preview + +on: + pull_request: + branches: [main] + +concurrency: + group: deploy-preview-${{ github.ref }} + cancel-in-progress: true + +jobs: + preview: + runs-on: ubuntu-latest + name: Build for Vercel preview + if: ${{ secrets.VERCEL_TOKEN != '' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.12.x' + cache: 'npm' + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - run: npm test + - run: npm run build + - name: Deploy to Vercel (preview) + run: | + npm i -g vercel@latest + vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }} + vercel build --token=${{ secrets.VERCEL_TOKEN }} + URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }}) + echo "Preview URL: $URL" + gh pr comment ${{ github.event.pull_request.number }} --body "Preview deploy: $URL" + env: + GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index a547bf3..548dc01 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,12 @@ dist-ssr *.njsproj *.sln *.sw? + +# Crash dumps +*.stackdump + +# Test coverage +coverage + +# Build artifacts +.vite diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..0b4b321 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +node_modules +dist +build +coverage +.vite +*.min.js +*.min.css +package-lock.json +pnpm-lock.yaml +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..a37bf73 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "tabWidth": 2, + "printWidth": 100, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3466144 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,78 @@ +# Contributing to Reactisma + +Thanks for considering a contribution. Reactisma is the companion repo of the Reactisma course, so a few conventions matter more here than in a typical OSS project: + +1. Every change should be educational. If a refactor is "just" cleaner, write a paragraph in the PR explaining the **why**. +2. PRs map 1:1 with course modules where possible. If your change overlaps a planned module, please coordinate first (open an issue). +3. We prefer **boring, readable code**. No clever one-liners that future course readers will trip over. + +--- + +## Workflow + +1. Fork the repo and clone your fork. +2. Create a branch off `main`: `git checkout -b feat/short-description`. +3. Make changes, run `npm run typecheck`, `npm run lint`, `npm test`. +4. Commit using **conventional commits** (see below). +5. Push and open a PR against `main`. Fill out the template. + +--- + +## Conventional commits + +Format: `(): ` + +Types we use: + +| Type | When to use | +| --- | --- | +| `feat` | A new feature or capability | +| `fix` | A bug fix | +| `perf` | A performance improvement | +| `refactor` | Code change that neither fixes a bug nor adds a feature | +| `docs` | Documentation only | +| `test` | Adding/updating tests | +| `chore` | Tooling, build, dependencies | +| `style` | Formatting only (no logic change) | + +Scopes are loose but try to be one of: `vdom`, `hooks`, `scheduler`, `reconciler`, `dom`, `styled`, `nn`, `memory`, `ssr`, `worker`, `wasm`, `devtools`, `course`. + +Examples: + +``` +feat(hooks): add useReducer with action discrimination via TS narrowing +fix(reconciler): preserve component instance when reordering keyed children +perf(nn): switch dual hemispheres to Float32Array layout +docs(course): expand module 4 outline with diagram links +``` + +--- + +## PR checklist + +Before requesting review, please confirm: + +- [ ] `npm run typecheck` passes +- [ ] `npm run lint` passes (no new warnings either) +- [ ] `npm run format:check` passes +- [ ] `npm test` passes +- [ ] The PR has a description explaining **why** (not just what) +- [ ] If the change is part of a course module, the module is referenced +- [ ] No leftover `console.log`, commented-out code, or dead imports + +--- + +## Code style + +- TypeScript strict mode is non-negotiable. Don't widen types with `any`; use `unknown` + narrowing. +- Functions should fit on a screen. If they don't, split them. +- Prefer pure functions in `utils/`. Side effects belong in components or in clearly-named lifecycle hooks. +- Hooks must follow the rules of hooks. We will land a custom ESLint rule for this in M3. + +## Reporting issues + +Open an issue with a reproduction case (link to a fork, a CodeSandbox, or a minimal `.tsx` snippet) and the expected vs. actual behavior. Tag with `bug`, `enhancement`, `question`, or `course`. + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/README.md b/README.md index 7dbf7eb..a7e4f9e 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,138 @@ -# React + TypeScript + Vite +# Reactisma — Nexus -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +A production-grade reimplementation of React (Virtual DOM, hooks, scheduler) built from scratch in TypeScript, paired with a dual-brain neural network with emotional memory that recognizes hand-drawn digits in the browser. -Currently, two official plugins are available: +This repository is also the companion project of the **Reactisma course** for senior React developers — every module of the course produces a real PR that ships to `main`. -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +> Status: pre-1.0. The library is being progressively hardened over 16 PRs. See [the course roadmap](#course-roadmap) below. -## React Compiler +--- -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +## Why this exists -## Expanding the ESLint configuration +- **Understand React from first principles.** Most developers can use React. Few have written one. Reactisma exists so you can read the entire VDOM, the scheduler, the hooks system and the reconciler end-to-end in TypeScript. +- **Cover the parts most tutorials skip.** Concurrent rendering, time-slicing, Suspense, SSR with hydration, synthetic events with batching, a Fiber-like work loop, and a typed CSS-in-JS library with atomic CSS and SSR critical extraction. +- **Practical, not toy.** The app is a working neural-network playground: you draw, the network trains in a Web Worker with WASM matmul, an LSH-indexed emotional memory recalls similar past examples. -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: +--- -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... +## Quick start - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, +Prerequisites: **Node.js 20.19+** (or 22.12+), npm 10+. - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +git clone https://github.com//reactisma-nexus.git +cd reactisma-nexus +npm install +npm run dev ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +Open the URL printed in the terminal (typically `http://localhost:5173`). + +### Scripts + +| Command | What it does | +| --- | --- | +| `npm run dev` | Start Vite dev server with HMR | +| `npm run build` | Type-check, then build the production bundle | +| `npm run preview` | Preview the production build locally | +| `npm run typecheck` | `tsc -b --noEmit` | +| `npm run lint` | ESLint over the whole repo | +| `npm run lint:fix` | ESLint with `--fix` | +| `npm run format` | Prettier write | +| `npm run format:check` | Prettier check | +| `npm test` | Test runner (Vitest, lands in M9/PR #10) | + +--- + +## Architecture + +``` +src/ +├── libs/ +│ ├── Reactisma/ # Custom React-like library +│ │ ├── core/ # createElement, render, diff, scheduler, mount/unmount +│ │ ├── hooks/ # useState, useRef, useEffect, useCallback (more landing) +│ │ ├── dom/ # Prop pipeline: events, style, refs, controlled inputs +│ │ └── index.ts # Public API (Reactisma default export) +│ └── StyledComponent/ # Tagged-template-literal CSS-in-JS +│ ├── index.ts # styled, ThemeProvider, keyframes (WIP) +│ ├── registry.ts # Style cache and DOM injection +│ └── utils.ts # snake-case ↔ camelCase helpers +│ +├── classes/ +│ ├── DualBrainNN.ts # Dual-hemisphere neural network (sigmoid + tanh + softmax) +│ └── EmotionalMemory.ts # k-NN over (input, emotion) tuples +│ +├── components/ # Demo app components (App, DrawingCanvas, ...) +├── hooks/ # App-level hooks: useDrawing, useDebounce +├── utils/ # activations, random, emotion extraction +├── styles/styled.ts # Concrete styled components for the demo +├── jsx.d.ts # JSX namespace for Reactisma +└── main.tsx # Entry point ``` + +### Reactisma data flow + +``` +JSX (component returns) + │ + ▼ +createElement → VNode tree + │ + ▼ +mount (first render) ─► DOM nodes +diff (subsequent render) ─► minimal DOM mutations + │ + ▼ +scheduler (microtasks → fiber-like loop in M4) + │ + ▼ +hooks: state, refs, effects, callbacks (more in M3/M5) +``` + +### Dual-brain neural network + +The network has two parallel hidden layers ("hemispheres") that process the same input: + +- **Left hemisphere** — sigmoid activation. Smoother, bounded `[0, 1]` outputs. +- **Right hemisphere** — `tanh` activation. Centered around zero, `[-1, 1]`. + +Their outputs are concatenated and fed through an integration layer, then a softmax output layer. The architecture, weights, and learning algorithm are all production-grade after M12 (Float32Array, Xavier init, softmax + cross-entropy, Adam, dropout, L2). + +The **emotional memory** stores past examples tagged with the prediction class plus three "emotional" descriptors of the drawing (density, symmetry, complexity). When the network is uncertain, the memory is queried via cosine similarity weighted by emotional proximity. After M14 the lookup uses LSH for sub-linear retrieval. + +--- + +## Course roadmap + +| Module | Title | PR | Status | +| ---: | --- | ---: | :---: | +| 0 | Setup and repo archaeology | #1 | in progress | +| 1 | Virtual DOM and JSX from scratch | #2 | planned | +| 2 | Reconciliation, keys, and unmount cleanup | #3 | planned | +| 3 | Deep hooks: state, deps, rules | #4 | planned | +| 4 | Fiber-like work loop and priorities | #5 | planned | +| 5 | Advanced hooks, Fragment, Error Boundaries | #6 | planned | +| 6 | Synthetic events, real batching, DevTools | #7 | planned | +| 7 | StyledComponent v1: parser, typing, insertRule | #8 | planned | +| 8 | StyledComponent v2: Atomic CSS, Theme, SSR, DevTools | #9 | planned | +| 9 | Testing: Vitest, custom testing-library, CI | #10 | planned | +| 10 | Concurrent rendering, Suspense, lazy | #11 | planned | +| 11 | SSR and hydration | #12 | planned | +| 12 | Production-grade NN (Float32Array, Adam, dropout) | #13 | planned | +| 13 | Web Workers and WASM | #14 | planned | +| 14 | Scalable emotional memory, IndexedDB | #15 | planned | +| 15 | Playground, visualization, deploy | #16 | planned | + +Each PR is mergeable on `main` and ships independently. The repository state at any point reflects the cumulative work up to that module. + +--- + +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md). PRs and issues are welcome; conventional commits are required. + +## License + +MIT diff --git a/docs/manual-tests/m1-dom-dispatcher.md b/docs/manual-tests/m1-dom-dispatcher.md new file mode 100644 index 0000000..86412ac --- /dev/null +++ b/docs/manual-tests/m1-dom-dispatcher.md @@ -0,0 +1,188 @@ +# Module 1 — Manual tests for the DOM prop dispatcher + +These tests verify the behavior of the typed prop dispatcher introduced in PR #2 (`src/libs/Reactisma/dom/`). Automated coverage lands in M9/PR #10 (Vitest). Until then, run these by hand in the browser. + +## How to run + +```bash +npm run dev +``` + +Open the app in the browser, then open DevTools. For each section below, follow the steps and check the result. When a step references a file, click the link to inspect the implementation. + +--- + +## 1. Event listener swap does not leak + +**What it verifies:** removing the previous listener BEFORE adding the new one prevents double-firing across renders. See [src/libs/Reactisma/dom/handlers/event.ts](../../src/libs/Reactisma/dom/handlers/event.ts). + +**Steps:** + +1. Open DevTools → Performance Monitor (or `chrome://devtools` → Memory). +2. Click the **Predict** button 50 times. +3. Open DevTools → Elements, select the `Predict` button. +4. In Console, run: `getEventListeners($0)`. + +**Expected:** `click` listeners count is exactly 1. Before the dispatcher, every render added a new listener (leak). + +--- + +## 2. Event removed on prop deletion + +**What it verifies:** `eventHandler.cleanup` removes the listener when the prop disappears entirely. + +**Steps (white-box):** + +1. In `src/components/App.tsx`, temporarily wrap the ` +``` + +**Steps:** + +1. Open DevTools → Elements. Expand the `DigitButtons` container. +2. Right-click the button for digit `0` → "Store as global variable" → `temp1`. +3. Click **reverse**. +4. In Console: `temp1.textContent`. + +**Expected:** +- The DOM node `temp1` still exists (was moved, not recreated). +- `temp1.textContent === '0'`. +- All ten buttons are now in reverse order in the DOM. + +Without keys (or with `key={index}`), `temp1` would have its text replaced with `9` because the reconciler reuses by position. + +--- + +## 2. Focus is preserved across reorder + +**Steps (continues from test 1):** + +1. Click an empty area to lose focus, then `Tab` into the buttons. The digit `0` is focused (visible outline). +2. Click **reverse**. + +**Expected:** the button for digit `0` is still focused (it just moved to the end of the row, focus rings travels with the element). + +With index-based reconciliation, focus would stay on the **first** DOM node (now digit `9`), which is wrong. + +--- + +## 3. New element inserted in the middle does not re-mount the rest + +**Setup (temporary):** + +In `App.tsx`: + +```tsx +const [digits, setDigits] = useState(() => [0,1,2,3,4,5,6,7,8,9]); +// Insert "10" between 4 and 5: + +``` + +**Steps:** + +1. Note the DOM node of digit `5` (store as `$0` via DevTools). +2. Click **insert 10**. + +**Expected:** the DOM node of digit `5` is the SAME instance (still `$0`). Only one new node was inserted before it. Without keys, every node from index 5 onwards is "rewritten in place". + +--- + +## 4. Removed key triggers unmount and effect cleanup + +**Setup (temporary):** + +Add a child component with a logged effect: + +```tsx +const Beep = ({ id }: { id: number }) => { + useEffect(() => { + console.log('mount', id); + return () => console.log('unmount', id); + }, []); + return {id}; +}; +``` + +In `App.tsx`: + +```tsx +const [ids, setIds] = useState(() => [1, 2, 3]); +
{ids.map((id) => )}
+ +``` + +**Steps:** + +1. Reload. Console shows `mount 1`, `mount 2`, `mount 3`. +2. Click **remove 2**. + +**Expected:** Console shows `unmount 2` (and nothing else). With index-based reconciliation, you would see `unmount 3` (the last one) plus a state corruption because the surviving instances would have been shuffled. + +--- + +## 5. Mixed keyed + unkeyed children + +**What it verifies:** the reconciler handles a list with some keyed and some unkeyed siblings without confusion. Unkeyed siblings are matched positionally among themselves. + +**Setup (temporary):** + +```tsx +
+ A + plain1 + B + plain2 +
+``` + +Reorder to `b, plain1, a, plain2` and re-render. + +**Expected:** the keyed `A` and `B` swap positions; the two unkeyed ``s reuse the existing DOM nodes in order. No crashes, no double-mount. + +--- + +## 6. Same component type, different keys → different instances + +**Setup (temporary):** + +```tsx +const Counter = () => { + const [n, setN] = useState(0); + return ; +}; + +// In App: +const [keyVal, setKeyVal] = useState('A'); + + +``` + +**Steps:** + +1. Click the counter 3 times. It says `3`. +2. Click **swap key**. + +**Expected:** the counter resets to `0`. Different `key` means a new instance. + +Without the keyed path, since `type` is unchanged, the same instance would be reused and the counter would stay at `3`. + +--- + +## 7. No keys → falls back to index reconciliation (existing behavior preserved) + +**Steps:** + +1. Do nothing special. The current `App.tsx` does not use keys. +2. Click around the app: draw, predict, train, change selected digit. + +**Expected:** everything still works as before. No console errors. The non-keyed fallback path is the original index-based behavior. + +--- + +## 8. `__$virtualDOM` is updated on every render + +**Steps:** + +1. Inspect any element in DevTools. +2. In Console: `$0.__$virtualDOM`. +3. Trigger a re-render (e.g. draw on canvas). +4. Run `$0.__$virtualDOM` again. + +**Expected:** the object reference may change (new VNode each render) but `type`, `props`, and (when applicable) `key` are coherent with the latest render. + +--- + +## Known limitation (deferred to M4) + +A DOM node carries only ONE `__$componentInstance` reference. When components nest (e.g. `div`), only the innermost instance is tracked at the boundary node. Cleanup of outer hooks works because each component is rooted at the same DOM node and `runCleanupEffects` is called on the visible instance, but if outer and inner instances both register effects, only the inner cleanup runs reliably on unmount. + +A real Fiber-like architecture (M4 / PR #5) replaces this with an explicit component tree where every level has its own list of hooks. diff --git a/docs/manual-tests/m3-advanced-hooks.md b/docs/manual-tests/m3-advanced-hooks.md new file mode 100644 index 0000000..1b202e5 --- /dev/null +++ b/docs/manual-tests/m3-advanced-hooks.md @@ -0,0 +1,235 @@ +# Module 3 — Manual tests for advanced hooks + +PR #4 adds `useReducer`, `useMemo`, `useContext` + `createContext`, `useDeepCompareEffect` to Reactisma, plus an `Object.is` bailout in `useState`. Automated coverage lands in M9/PR #10. + +## How to run + +```bash +npm run dev +``` + +Open the app and follow the steps. Most tests need a small temporary code change in `src/components/App.tsx`. Revert each block after testing. + +--- + +## 1. `useState` bailout with `Object.is` + +**What it verifies:** setting the same value twice does not trigger a re-render. See [hooks/hooks.ts:useState](../../src/libs/Reactisma/hooks/hooks.ts). + +**Setup (temporary, in `App.tsx`):** + +```tsx +const [n, setN] = useState(0); +useEffect(() => { + console.log('App rendered, n =', n); +}); + +``` + +**Steps:** + +1. Reload. Console logs `App rendered, n = 0`. +2. Click **set 0** ten times. + +**Expected:** the log does NOT repeat. With `!==`, `NaN !== NaN` would be true and would have caused spurious re-renders for `NaN` state. With `Object.is(NaN, NaN) === true`, the bailout is correct. + +Now try `setN((x) => x)` (returning the same reference). Same result: no re-render. + +--- + +## 2. `useReducer` discriminated actions + +**Setup (temporary):** + +```tsx +type CounterAction = { type: 'inc' } | { type: 'dec' } | { type: 'reset'; payload: number }; +const counterReducer = (state: number, action: CounterAction): number => { + switch (action.type) { + case 'inc': return state + 1; + case 'dec': return state - 1; + case 'reset': return action.payload; + } +}; +const [count, dispatch] = useReducer(counterReducer, 0); +// In the JSX: +
+ + + + {count} +
+``` + +**Steps:** + +1. Click `+1` five times → `5`. +2. Click `-1` twice → `3`. +3. Click `reset` → `100`. + +**Expected:** the counter updates correctly. TS should error if you forget a case (`reset` requires payload). + +**Lazy init test:** + +```tsx +const [count, dispatch] = useReducer(counterReducer, 0, (i) => i + 1000); +``` + +Reload. Initial `count === 1001` (init function was called once). + +--- + +## 3. `useMemo` recomputes only when deps change + +**Setup (temporary):** + +```tsx +const [n, setN] = useState(0); +const [other, setOther] = useState(''); +const expensive = useMemo(() => { + console.log('recomputing for n =', n); + return n * n; +}, [n]); +
+ setOther((e.target as HTMLInputElement).value)} /> + + square: {expensive} +
+``` + +**Steps:** + +1. Type in the input. Console does NOT log "recomputing". +2. Click `n+1`. Console logs once per click. + +**Expected:** the factory runs only when `n` changes, not on every render triggered by `other`. + +--- + +## 4. `useContext` reads the nearest Provider + +**Setup (temporary):** + +```tsx +const ThemeContext = createContext<'light' | 'dark'>('light'); + +const Themed = () => { + const theme = useContext(ThemeContext); + return theme is {theme}; +}; + +const App = () => { + const [t, setT] = useState<'light' | 'dark'>('light'); + return ( + + + + + ); +}; +``` + +**Steps:** + +1. Reload. The span reads "theme is light". +2. Click `toggle`. Span reads "theme is dark". + +**Expected:** `Themed` re-renders with the new value as the Provider's `value` changes. + +**Nested providers:** + +```tsx + + + + + + +``` + +Reload. First `Themed` reads `light`, second reads `dark`. (Static example; if you toggle the outer, only the first changes.) + +**Default value:** + +`` outside any Provider reads `'light'` (the default passed to `createContext`). + +--- + +## 5. `useDeepCompareEffect` does not re-fire on identity-only changes + +**Setup (temporary):** + +```tsx +const [tick, setTick] = useState(0); +// Recreated on every render → reference changes: +const options = { window: 28, channels: 3 }; +useDeepCompareEffect(() => { + console.log('options effect ran'); +}, [options]); + +``` + +**Steps:** + +1. Reload. Console logs `options effect ran` once. +2. Click `tick` 5 times. + +**Expected:** the log does NOT repeat. `useEffect(fn, [options])` would re-fire 5 times because `options` is a fresh object each render. `useDeepCompareEffect` compares by structure. + +Now modify a key: + +```tsx +const options = { window: 28 + (tick % 3), channels: 3 }; +``` + +Reload, click 4 times. Effect fires when `window` actually changes value. + +--- + +## 6. Provider stack push/pop balance + +**What it verifies:** the provider stack returns to its pre-render state after a re-render. A leak would manifest as `useContext` returning stale values after enough renders. + +**Setup (temporary):** + +```tsx +const Probe = () => { + const v = useContext(ThemeContext); + useEffect(() => console.log('probe sees', v)); + return null; +}; +// In App, render OUTSIDE any provider, plus an inner case under +// the provider, and trigger 100 re-renders of the App. +``` + +**Steps:** + +1. Click any state-changing button rapidly (e.g. **toggle** above) 50 times. +2. Render a `` outside the provider. + +**Expected:** the outside probe always logs `'light'` (the default), never a leaked value from the provider. The pop runs after each provider subtree, regardless of how many times the tree was reconciled. + +--- + +## 7. `useReducer` integrates with re-render bailout + +**Setup (temporary):** + +```tsx +const noopReducer = (state: number, _action: 'noop'): number => state; +const [n, dispatch] = useReducer(noopReducer, 0); +useEffect(() => console.log('rerender')); + +``` + +**Steps:** + +1. Reload. Console logs `rerender` once. +2. Click `noop` 10 times. + +**Expected:** the log does NOT repeat. The reducer returns the same state, the `Object.is` bailout inside `setState` skips the re-render. + +--- + +## Known limitation + +A DOM node still carries only ONE `__$componentInstance`. When `Provider` wraps a child, the child's instance overwrites the Provider's instance reference at the same DOM node. Cleanup of effects registered INSIDE the Provider component (rare) is not guaranteed. The Fiber-like architecture in M4 / PR #5 resolves this. diff --git a/docs/manual-tests/m4-scheduler.md b/docs/manual-tests/m4-scheduler.md new file mode 100644 index 0000000..c7a624f --- /dev/null +++ b/docs/manual-tests/m4-scheduler.md @@ -0,0 +1,215 @@ +# Module 4 — Manual tests for the priority scheduler + +PR #5 introduces `src/libs/Reactisma/scheduler/` — a priority queue with `MessageChannel`-based time-slicing. `core/scheduler.ts` now routes every render through it. + +## What this PR contains, what it does not + +**In:** + +- 4-tier priority model (Immediate, UserBlocking, Normal, Idle) with timeout-derived `expirationTime`. +- A binary-inserted sorted queue. +- A host loop driven by `MessageChannel` (with `setTimeout` fallback for non-browser environments). +- `shouldYield()` that returns `true` once the current 5 ms slice is consumed. +- `runWithPriority(priority, fn)` + `startTransition(fn)` to mark a chunk of work as non-urgent. + +**Out (deferred to a follow-up):** + +- Work-loop-by-fiber interruptibility. Today, a single render still walks the VNode tree synchronously inside one task; the new scheduler only slices BETWEEN root renders, not within them. The Fiber node skeleton lands when the reconciler is rewritten in iterative form. + +## How to run + +```bash +npm run dev +``` + +Tests below need the browser DevTools Performance tab and one temporary code edit in `src/components/App.tsx` per scenario. + +--- + +## 1. Renders are batched into one scheduler task + +**Setup (temporary, App.tsx):** + +```tsx +const [a, setA] = useState(0); +const [b, setB] = useState(0); +const [c, setC] = useState(0); + +const bumpAll = () => { + setA(a + 1); + setB(b + 1); + setC(c + 1); +}; + +useEffect(() => { + console.log('render', { a, b, c }); +}); + + +``` + +**Steps:** + +1. Reload. Console logs once with `0, 0, 0`. +2. Click **bump three**. + +**Expected:** the console logs ONCE with `1, 1, 1`, not three times. The scheduler's `pendingTask` guard collapses simultaneous `scheduleRender` calls into one task. + +--- + +## 2. `startTransition` yields to user-blocking input + +**Setup (temporary):** + +```tsx +import Reactisma from '../libs/Reactisma'; +const { startTransition } = Reactisma; + +const [text, setText] = useState(''); +const [list, setList] = useState([]); + +const onInput = (e: Event) => { + setText((e.target as HTMLInputElement).value); // urgent (Normal) + startTransition(() => { + // Expensive: produce 5000 derived numbers as Idle priority + const huge = Array.from({ length: 5000 }, (_, i) => i * Math.random()); + setList(huge); + }); +}; + + +
{list.length} items
+``` + +**Steps:** + +1. Reload. Type fast into the input. + +**Expected:** input keystrokes feel responsive even though the `list` update is heavy. With `startTransition` the list update is enqueued as Idle priority; with a plain `setList` you would feel jank. + +Open DevTools → Performance, record a few seconds while typing. The long tasks for `list` should be interleaved with short tasks for `text`. + +--- + +## 3. `shouldYield()` exposes the slice deadline + +**What it verifies:** inside a scheduled callback you can call `shouldYield()` to break a loop. Useful for any future work that wants to cooperate with the host. + +**Setup (temporary, somewhere you can run code):** + +```js +import { scheduleCallback, shouldYield, NormalPriority } from './libs/Reactisma/scheduler'; + +scheduleCallback(NormalPriority, () => { + let i = 0; + while (!shouldYield() && i < 1_000_000) i++; + console.log('processed', i, 'iterations before yielding'); +}); +``` + +**Expected:** the log shows somewhere in the order of 10^4–10^5 iterations (5 ms of work on a modern laptop). Re-run several times — the number varies with CPU load. + +--- + +## 4. Cancelled tasks do not run + +**Setup (temporary):** + +```js +import { scheduleCallback, cancelTask, NormalPriority } from './libs/Reactisma/scheduler'; + +const t = scheduleCallback(NormalPriority, () => console.log('SHOULD NOT RUN')); +cancelTask(t); +``` + +**Expected:** the console stays silent. The flushWork loop checks the `cancelled` flag and skips the task. + +--- + +## 5. Expired tasks run even if `shouldYield()` would say "yield" + +**What it verifies:** an Immediate or UserBlocking task whose `expirationTime` has passed must run to completion, otherwise it would never get the chance to run on a busy page. + +**Setup (temporary):** + +```js +import { scheduleCallback, ImmediatePriority, NormalPriority } from './libs/Reactisma/scheduler'; + +scheduleCallback(NormalPriority, () => { + // Heavy work to consume the slice + const stop = performance.now() + 50; + while (performance.now() < stop) {} + console.log('heavy normal done'); +}); + +scheduleCallback(ImmediatePriority, () => { + console.log('immediate ran'); +}); +``` + +**Expected:** the second log appears immediately after the first (in the next host tick). The Immediate task was added AFTER but its `expirationTime` is in the past, so when the queue gets to it, `isExpired === true` and it runs through `shouldYield()`. + +--- + +## 6. Render priority honors the surrounding `runWithPriority` + +**Setup (temporary):** + +```tsx +import Reactisma from '../libs/Reactisma'; +const { runWithPriority, useState, useEffect } = Reactisma; +import { IdlePriority } from '../libs/Reactisma/scheduler'; + +const [n, setN] = useState(0); +useEffect(() => console.log('rendered', n)); + +const idleBump = () => runWithPriority(IdlePriority, () => setN((x) => x + 1)); + +``` + +**Steps:** + +1. Click **idle bump**. + +**Expected:** the render still happens (microseconds-to-milliseconds later depending on browser load). If you put an artificial heavy task at Normal priority before clicking, the idle bump will visibly wait for the normal task to finish. + +--- + +## 7. Pending render is collapsed across hooks + +**Setup (temporary):** + +```tsx +const [x, setX] = useState(0); +useEffect(() => { + // Trigger another render inside an effect — synchronously this time + if (x === 0) setX(1); +}, [x]); +``` + +**Expected:** no infinite loop, no double-render storm. The second `setX(1)` is collapsed by the `pendingTask` guard until the current commit finishes. After that, exactly one re-render runs with `x === 1`, the effect runs, sees `x !== 0`, and the loop terminates. + +--- + +## Performance check (informal) + +In the DevTools console: + +```js +performance.mark('start'); +for (let i = 0; i < 1000; i++) { + // Force a render + document.querySelector('canvas')?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); +} +performance.mark('end'); +performance.measure('1000 paints', 'start', 'end'); +performance.getEntriesByName('1000 paints')[0].duration; +``` + +Compare the duration before and after this PR. Expect a small win (~5-15%) because the new scheduler avoids `Promise.resolve()` allocation per update and consolidates dispatches. + +--- + +## Known limitation + +The work inside one render is still synchronous. A 50 ms diff of a 10k-node tree will still block the main thread for 50 ms in this PR. The `shouldYield`/`MessageChannel` plumbing is in place; wiring it into a fiber-style `performUnitOfWork` is a follow-up that requires rewriting `diff.ts` and `mount-element.ts` from recursion to an iterative walker. The course module covers the design in detail; the iterative rewrite is the homework / next PR. diff --git a/docs/manual-tests/m5-advanced.md b/docs/manual-tests/m5-advanced.md new file mode 100644 index 0000000..2523b0a --- /dev/null +++ b/docs/manual-tests/m5-advanced.md @@ -0,0 +1,275 @@ +# Module 5 — Manual tests for advanced hooks, Fragment, Error Boundaries + +PR #6 adds `useLayoutEffect`, `useImperativeHandle`, `useId`, `useTransition`, `useDeferredValue`, `Fragment`, and `withErrorBoundary` to Reactisma. + +## How to run + +```bash +npm run dev +``` + +Each test requires a small temporary edit to `src/components/App.tsx` (or the file noted) — revert after testing. + +--- + +## 1. `useLayoutEffect` runs before paint + +**What it verifies:** `useLayoutEffect` drains synchronously after DOM mutations and before the regular `useEffect` queue. See [hooks/hooks.ts:useLayoutEffect](../../src/libs/Reactisma/hooks/hooks.ts) and [core/render.ts](../../src/libs/Reactisma/core/render.ts). + +**Setup (temporary):** + +```tsx +import Reactisma from '../libs/Reactisma'; +const { useState, useEffect, useLayoutEffect } = Reactisma; + +const [n, setN] = useState(0); +useLayoutEffect(() => console.log('layout', n)); +useEffect(() => console.log('effect', n)); + +``` + +**Steps:** + +1. Reload. Console logs (in order): `layout 0`, `effect 0`. +2. Click **bump**. Console logs: `layout 1`, `effect 1`. + +**Expected:** `layout` always precedes `effect` in the log order. With the old single-queue model, both would run interleaved by hook position. + +--- + +## 2. `useImperativeHandle` exposes methods through a ref + +**Setup (temporary):** + +```tsx +import Reactisma from '../libs/Reactisma'; +const { useRef, useState, useImperativeHandle } = Reactisma; + +type ChildAPI = { greet: () => string; reset: () => void }; + +const Child = ({ apiRef }: { apiRef: { current: ChildAPI | null } }) => { + const [n, setN] = useState(0); + useImperativeHandle(apiRef, () => ({ + greet: () => `hello from child, n=${n}`, + reset: () => setN(0), + }), [n]); + return {n}; +}; + +// In App: +const apiRef = useRef(null); + + +``` + +**Steps:** + +1. Reload. Click **greet** → console logs `hello from child, n=0`. +2. (Internally bump n with another button) → click **greet** → log reflects new n. + +**Expected:** the API is rebuilt only when its deps (`n`) change. + +--- + +## 3. `useId` is stable across renders, unique across components + +**Setup (temporary):** + +```tsx +import Reactisma from '../libs/Reactisma'; +const { useId, useState, useEffect } = Reactisma; + +const Counter = () => { + const id = useId(); + const [n, setN] = useState(0); + useEffect(() => console.log(`Counter ${id} rendered with n=${n}`)); + return ( +
+ + setN(Number((e.target as HTMLInputElement).value))} /> +
+ ); +}; + +// In App: render 3 instances: + +``` + +**Steps:** + +1. Reload. Console logs 3 distinct ids (e.g. `:r1:`, `:r2:`, `:r3:`). +2. Trigger several re-renders. The ids do NOT change. +3. Inspect any ``: the `id` attribute matches the `for` attribute of its `