|
| 1 | +# Best Practices Review: PR #67 |
| 2 | + |
| 3 | +**Score: 62 / 100** |
| 4 | + |
| 5 | +## Summary |
| 6 | + |
| 7 | +This PR introduces a significant refactoring of git space URL routing (switching from `git-` prefixed flat page IDs to filesystem-path-based page IDs with `::` subPath separators), adds a 404 Not Found page, improves the AppMenu with GitHub links, and adds inactive space stats loading. The test coverage is solid for the pure routing and space management logic, but there are several best practices concerns around React hooks usage, potential memory leaks, missing cleanup in async effects, and the growing complexity of App.tsx. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## Detailed Findings |
| 12 | + |
| 13 | +### 1. Missing Abort/Cleanup in Async useEffect (Memory Leak Risk) -- MAJOR |
| 14 | + |
| 15 | +**File:** `packages/ui/src/components/App.tsx`, lines 1129-1159 |
| 16 | + |
| 17 | +The `useEffect` that loads inactive space stats fires an async function (`loadStats`) that iterates over every inactive space, reads every page's content, and calls `setInactiveSpaceStats` at the end. There is no `AbortController` or cancelled-flag to prevent state updates after unmount or when dependencies change mid-flight. |
| 18 | + |
| 19 | +```tsx |
| 20 | +useEffect(() => { |
| 21 | + if (!spacesManifest) return; |
| 22 | + const loadStats = async () => { |
| 23 | + // ... potentially long-running loop over all spaces and pages ... |
| 24 | + setInactiveSpaceStats(stats); // May fire after unmount or stale closure |
| 25 | + }; |
| 26 | + void loadStats(); |
| 27 | + }, [spacesManifest, userSpaceId, backend]); |
| 28 | +``` |
| 29 | + |
| 30 | +This is a classic React memory leak pattern. If `spacesManifest` or `userSpaceId` changes while the previous load is still running, the old invocation will still call `setInactiveSpaceStats` with stale data, and if the component unmounts, React will warn about setting state on an unmounted component. |
| 31 | + |
| 32 | +**Recommendation:** Add a `let cancelled = false;` flag in the effect body, check it before `setInactiveSpaceStats`, and set `cancelled = true` in the cleanup return. |
| 33 | + |
| 34 | +### 2. N+1 Async Reads in useEffect (Performance Concern) -- MAJOR |
| 35 | + |
| 36 | +**File:** `packages/ui/src/components/App.tsx`, lines 1129-1159 |
| 37 | + |
| 38 | +The stats loading effect calls `readSpacePageContent` for every single page in every inactive space sequentially (nested for-loop with `await` in each iteration). For a user with multiple spaces containing many pages, this is an unbounded number of async I/O operations that could degrade performance. There is also no debouncing or caching -- this re-runs every time `spacesManifest` reference changes. |
| 39 | + |
| 40 | +Additionally, `flattenPages(spacePages)` is called twice in the same scope -- once for count and once for iteration -- which is wasteful. |
| 41 | + |
| 42 | +**Recommendation:** Consider batching reads, adding a debounce, or computing content size from metadata rather than reading every file. Cache the `flattenPages` result. |
| 43 | + |
| 44 | +### 3. `notFound` State Not Cleared on Successful Navigation -- MODERATE |
| 45 | + |
| 46 | +**File:** `packages/ui/src/components/App.tsx`, lines ~122, 253-267, 414-424 |
| 47 | + |
| 48 | +The `notFound` state is set when space lookup fails or clone fails, but it is only cleared by explicit user interaction (clicking "Go Home" or "Go to Docs" in the NotFoundPage component). If the user navigates via the browser back button (popstate), the `notFound` state is never cleared, so the 404 page could persist even when navigating to a valid route. |
| 49 | + |
| 50 | +**Recommendation:** Clear `setNotFound(null)` at the start of the `popstate` handler and at the start of the route restoration logic. |
| 51 | + |
| 52 | +### 4. Closure Over Stale `resolvedPageId` in Nested Promise Chains -- MODERATE |
| 53 | + |
| 54 | +**File:** `packages/ui/src/components/App.tsx`, lines 318-425 |
| 55 | + |
| 56 | +The `switchToExistingSpace` function is defined inside a `.then()` callback and closes over `resolvedPageId`. This is technically correct since `resolvedPageId` is a `const` in the same scope, but the deeply nested promise chains (`void loadSpaces(...).then(... void switchSpaceInBackend(...).then(... void loadAndApplySpaceState(...).then(...)))`) are hard to reason about and make it difficult to add proper error handling or cancellation. |
| 57 | + |
| 58 | +**Recommendation:** Convert to async/await for readability and add try/catch at the top level. |
| 59 | + |
| 60 | +### 5. `popstate` useEffect Dependency Array Includes Frequently Changing Values -- MODERATE |
| 61 | + |
| 62 | +**File:** `packages/ui/src/components/App.tsx`, line 569 |
| 63 | + |
| 64 | +```tsx |
| 65 | +}, [selectedPageId, pages, spacesManifest]); |
| 66 | +``` |
| 67 | + |
| 68 | +The `popstate` listener is torn down and re-added every time `selectedPageId`, `pages`, or `spacesManifest` changes. These values change frequently (every page selection, every tree edit). Each re-registration removes and re-adds the event listener. While not a bug, it causes unnecessary churn. Typically, popstate handlers should use refs for mutable values so the effect only runs once. |
| 69 | + |
| 70 | +**Recommendation:** Store `selectedPageId`, `pages`, and `spacesManifest` in refs and use a stable (empty or minimal) dependency array for the popstate listener. |
| 71 | + |
| 72 | +### 6. IIFE in JSX for `githubUrl` Prop -- MINOR |
| 73 | + |
| 74 | +**File:** `packages/ui/src/components/App.tsx`, lines 1264-1277 |
| 75 | + |
| 76 | +```tsx |
| 77 | +githubUrl={(() => { |
| 78 | + if (activeSpace === 'docs' && docsSelectedPageId) { |
| 79 | + return getDocsSourceUrl(docsSelectedPageId) ?? undefined; |
| 80 | + } |
| 81 | + // ... |
| 82 | +})()} |
| 83 | +``` |
| 84 | + |
| 85 | +An immediately-invoked function expression in JSX is a code smell. It runs on every render and makes the JSX harder to read. |
| 86 | + |
| 87 | +**Recommendation:** Extract this into a `useMemo` or a simple computed variable above the return statement. |
| 88 | + |
| 89 | +### 7. GitHub SVG Icon Duplicated Three Times -- MINOR |
| 90 | + |
| 91 | +**File:** `packages/ui/src/components/app-menu/AppMenu.tsx`, lines 37-45, 61-63, and the removed code from App.tsx |
| 92 | + |
| 93 | +The same 16x16 GitHub SVG path is copy-pasted in three locations. This violates DRY. |
| 94 | + |
| 95 | +**Recommendation:** Extract into a shared `GitHubIcon` component. |
| 96 | + |
| 97 | +### 8. `parseCeptYaml` Minimal Parser May Be Fragile -- MINOR |
| 98 | + |
| 99 | +**File:** `packages/ui/src/components/storage/git-space.ts`, lines ~110-147 |
| 100 | + |
| 101 | +The hand-rolled YAML parser only handles the `hide:` key with a specific subset of syntax. While it is intentionally minimal and documented as such, it does not handle edge cases like comments (`# comment`), multi-line strings, or nested structures. The tests cover the intended cases well, but any `.cept.yaml` with comments or unexpected formatting will silently produce incorrect results. |
| 102 | + |
| 103 | +**Recommendation:** Add a comment noting known limitations, or handle `#` comment lines. Consider using a tiny YAML parser library if the format grows. |
| 104 | + |
| 105 | +### 9. No Test for NotFoundPage Component -- MODERATE |
| 106 | + |
| 107 | +**File:** `packages/ui/src/components/not-found/NotFoundPage.tsx` |
| 108 | + |
| 109 | +The new `NotFoundPage` component has no unit or component tests. While it is a simple presentational component, the project's CLAUDE.md states "Every new UI component gets component tests." |
| 110 | + |
| 111 | +**Recommendation:** Add a basic component test verifying rendering with/without `path` and `message` props, and that callbacks fire on button clicks. |
| 112 | + |
| 113 | +### 10. No Tests for `pageIdToFilename` or `extractFrontMatter` -- MODERATE |
| 114 | + |
| 115 | +**File:** `packages/ui/src/components/storage/StorageContext.tsx`, lines ~261-266 and `git-space.ts` lines ~153-163 |
| 116 | + |
| 117 | +Two new utility functions (`pageIdToFilename` and `extractFrontMatter`) were added without dedicated unit tests. `pageIdToFilename` has edge cases (what about `.markdown` in the middle of a name? double extensions like `file.md.md`?). `extractFrontMatter` has edge cases around malformed YAML, missing closing `---`, etc. |
| 118 | + |
| 119 | +**Recommendation:** Add unit tests for these functions, especially edge cases. |
| 120 | + |
| 121 | +### 11. TypeScript Strict Mode Compliance -- OK |
| 122 | + |
| 123 | +The code uses proper TypeScript types throughout. No instances of `any` or `@ts-ignore` were found in the diff. The `SpacesManifest` type is properly imported for test fixtures. The `resolveRouteToSpace` function has well-typed parameters and return values. The `Match` type alias in the function body is appropriately scoped. |
| 124 | + |
| 125 | +### 12. Test Quality Assessment -- GOOD |
| 126 | + |
| 127 | +The router tests (`router.test.ts`) are thorough with excellent coverage of: |
| 128 | +- Base path variations (root, custom base, preview base) |
| 129 | +- Git space URL parsing with file extensions, nested paths, legacy formats |
| 130 | +- Roundtrip tests (build -> parse -> build produces stable URLs) |
| 131 | +- Collision prevention test for the old `git-` prefix scheme |
| 132 | +- `afterEach` cleanup of global state (`setBasePath`, `setUseGitPrefix`) |
| 133 | + |
| 134 | +The `SpaceManager.test.ts` tests for `resolveRouteToSpace` are well-structured with good edge case coverage including multi-segment branch names. |
| 135 | + |
| 136 | +The `git-space.test.ts` `parseCeptYaml` tests cover the intended YAML subset well. |
| 137 | + |
| 138 | +### 13. `resolveRouteToSpace` Scoring Logic -- OK but Complex |
| 139 | + |
| 140 | +**File:** `packages/ui/src/components/storage/SpaceManager.ts`, lines ~262-348 |
| 141 | + |
| 142 | +The scoring logic (`score: spaceBranch.length * 10 + (space.subPath?.length ?? 0)`) is reasonable for disambiguation but could benefit from a comment explaining why `* 10` is used (to ensure branch length dominates over subPath length). The function is well-documented with JSDoc. |
| 143 | + |
| 144 | +--- |
| 145 | + |
| 146 | +## Inline Comments |
| 147 | + |
| 148 | +| File | Line(s) | Comment | |
| 149 | +|------|---------|---------| |
| 150 | +| `packages/ui/src/components/App.tsx` | 1129-1159 | **Memory leak:** async `loadStats` has no cancellation mechanism. Add `let cancelled = false` pattern. | |
| 151 | +| `packages/ui/src/components/App.tsx` | 1142 | **Performance:** `flattenPages(spacePages)` is called twice; cache the result. | |
| 152 | +| `packages/ui/src/components/App.tsx` | 569 | **Churn:** `popstate` listener re-registered on every page/tree change. Use refs for mutable values. | |
| 153 | +| `packages/ui/src/components/App.tsx` | 1264-1277 | **Readability:** Extract IIFE into a `useMemo` or variable. | |
| 154 | +| `packages/ui/src/components/App.tsx` | 414-416 | **Error UX:** Clone failure shows 404 but `cloneStatus.error` was previously set. Verify the error path is still surfaced in UI (the clone dialog may no longer show the error). | |
| 155 | +| `packages/ui/src/components/App.tsx` | 546-569 | **Bug risk:** `notFound` state is not cleared in the popstate handler, so a 404 page can persist after back-navigation. | |
| 156 | +| `packages/ui/src/components/app-menu/AppMenu.tsx` | 37-45, 61-63 | **DRY:** GitHub SVG icon duplicated. Extract to shared component. | |
| 157 | +| `packages/ui/src/components/not-found/NotFoundPage.tsx` | 1-44 | **Missing tests:** No component tests for this new component. | |
| 158 | +| `packages/ui/src/components/storage/StorageContext.tsx` | 262-265 | **Missing tests:** `pageIdToFilename` needs edge case tests (double extensions, path-like page IDs). | |
| 159 | +| `packages/ui/src/components/storage/git-space.ts` | 153-163 | **Missing tests:** `extractFrontMatter` has no tests; edge cases like missing closing `---` are untested. | |
| 160 | +| `packages/ui/src/components/storage/git-space.ts` | 141 | **Edge case:** The condition `!trimmed.startsWith('-') && !trimmed.startsWith(' ')` means a line like ` other: value` (indented) would not terminate the list. This may be intentional but is undocumented. | |
| 161 | +| `packages/ui/src/components/storage/SpaceManager.ts` | ~330 | **Magic number:** `spaceBranch.length * 10` scoring factor deserves a brief comment explaining the rationale. | |
| 162 | + |
| 163 | +--- |
| 164 | + |
| 165 | +## References |
| 166 | + |
| 167 | +- `packages/ui/src/components/App.tsx` -- Main application component with hooks and routing logic |
| 168 | +- `packages/ui/src/router.ts` -- URL routing implementation |
| 169 | +- `packages/ui/src/router.test.ts` -- Router test suite (comprehensive) |
| 170 | +- `packages/ui/src/components/storage/SpaceManager.ts` -- Space management and `resolveRouteToSpace` |
| 171 | +- `packages/ui/src/components/storage/SpaceManager.test.ts` -- Space manager tests (comprehensive) |
| 172 | +- `packages/ui/src/components/storage/git-space.ts` -- Git space cloning, YAML parsing, file walking |
| 173 | +- `packages/ui/src/components/storage/git-space.test.ts` -- Git space tests (good for covered functions) |
| 174 | +- `packages/ui/src/components/storage/StorageContext.tsx` -- Storage utilities with `pageIdToFilename` |
| 175 | +- `packages/ui/src/components/not-found/NotFoundPage.tsx` -- New 404 component (no tests) |
| 176 | +- `packages/ui/src/components/app-menu/AppMenu.tsx` -- AppMenu with new GitHub link |
0 commit comments