Skip to content

Commit 1cf1ab9

Browse files
d0x2fclaude
andauthored
fix: address bugs B1–B8 from IMPROVEMENTS.md (#213)
* fix: address bugs B1–B8 from IMPROVEMENTS.md B1 (App.svelte): store media query handler in named var so removeEventListener removes the correct reference B2 (Menu.svelte): move ClipboardJS into onMount, narrow selector to [data-clipboard-text], destroy on cleanup B3 (Board.svelte): guard accepts callback against missing card during store mid-update B4 (Board.svelte): guard drop handler against missing card during store mid-update B5 (api.js): throw on non-OK HTTP status in requestJson so callers can distinguish failure B6 (firestore.js): optional-chain column/owner in normaliseCard to survive partial Firestore writes B7 (firestore.js): replace private _document field access with Date.now() fallback B8 (store.js): cancel stale passwordValid promises with a generation flag to prevent stale results Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: handle requestJson errors in Board.svelte callers; remove bugs section - getBoard (onMount): wrap in try/catch so HTTP 404 navigates to /not-found instead of propagating as an unhandled rejection - updateBoard (board subscriber): use .catch() since the call is fire-and-forget and a sync try/catch cannot catch async errors - Remove resolved Bugs section from IMPROVEMENTS.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ea3917a commit 1cf1ab9

7 files changed

Lines changed: 35 additions & 204 deletions

File tree

IMPROVEMENTS.md

Lines changed: 0 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -4,187 +4,6 @@ Findings from a comprehensive review of the retro.tools frontend. Covers Svelte
44

55
---
66

7-
## Bugs
8-
9-
### B1 — Memory leak in dark mode media query listener (`src/App.svelte:20–34`)
10-
11-
**Severity: High**
12-
13-
The `change` listener on `prefersDarkScheme` is added as an anonymous arrow function on line 20, but the `onMount` cleanup on line 33 tries to remove `darkModeChangeListener` — a different function reference used for the colorMode subscription. The anonymous media-query listener is never removed, leaking on every unmount.
14-
15-
```js
16-
// Current (line 20): listener added as anonymous function
17-
prefersDarkScheme.addEventListener("change", (e) => { ... });
18-
19-
// Current (line 33): removes the WRONG function
20-
return () => {
21-
unsubscribeDarkModeChange();
22-
prefersDarkScheme.removeEventListener("change", darkModeChangeListener); // wrong ref
23-
};
24-
```
25-
26-
**Fix:** Store the handler in a named variable and reference it in both `addEventListener` and `removeEventListener`.
27-
28-
```js
29-
const handleSchemeChange = (e) => { ... };
30-
prefersDarkScheme.addEventListener("change", handleSchemeChange);
31-
return () => {
32-
unsubscribeDarkModeChange();
33-
prefersDarkScheme.removeEventListener("change", handleSchemeChange);
34-
};
35-
```
36-
37-
---
38-
39-
### B2 — ClipboardJS instance never stored or destroyed (`src/components/Menu.svelte:42`)
40-
41-
**Severity: High**
42-
43-
`new ClipboardJS("button")` runs at module scope (outside any lifecycle hook), so the instance is created but never stored or `.destroy()`-ed when the component unmounts. The `"button"` selector also matches all buttons in the document, including those not yet mounted.
44-
45-
**Fix:** Move into `onMount`, store the instance, destroy it in the cleanup:
46-
47-
```js
48-
import { onMount } from "svelte";
49-
let clipboard;
50-
onMount(() => {
51-
clipboard = new ClipboardJS("[data-clipboard-text]");
52-
return () => clipboard.destroy();
53-
});
54-
```
55-
56-
---
57-
58-
### B3 — Null dereference in drag `accepts` callback (`src/Board.svelte:62`)
59-
60-
**Severity: High**
61-
62-
```js
63-
accepts: (el, target) => {
64-
return target.dataset.rankId !== $cards.find((c) => c.id === el.dataset.cardId).column;
65-
},
66-
```
67-
68-
If the card is not found in `$cards` (e.g. the store is mid-update), `.column` throws a `TypeError` and crashes the drag handler.
69-
70-
**Fix:** Guard against a missing card:
71-
72-
```js
73-
accepts: (el, target) => {
74-
const card = $cards.find((c) => c.id === el.dataset.cardId);
75-
return card != null && target.dataset.rankId !== card.column;
76-
},
77-
```
78-
79-
---
80-
81-
### B4 — Null dereference in drag `drop` handler (`src/Board.svelte:80`)
82-
83-
**Severity: High**
84-
85-
Same pattern as B3. `const card = $cards.find(...)` is used immediately without a null check. If the card is absent, subsequent property access throws.
86-
87-
**Fix:**
88-
89-
```js
90-
drake.on("drop", async (el, target) => {
91-
const card = $cards.find((c) => c.id === el.dataset.cardId);
92-
if (!card) return;
93-
...
94-
});
95-
```
96-
97-
---
98-
99-
### B5 — `requestJson` doesn't check HTTP status (`src/api.js:16–18`)
100-
101-
**Severity: Medium**
102-
103-
```js
104-
async function requestJson(input, init) {
105-
return (await fetch(input, init)).json();
106-
}
107-
```
108-
109-
On a 4xx or 5xx response, `.json()` either throws a parse error (if the body isn't JSON) or silently decodes the error body as if it were valid data. Callers have no way to distinguish success from failure.
110-
111-
**Fix:**
112-
113-
```js
114-
async function requestJson(input, init) {
115-
const response = await fetch(input, init);
116-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
117-
return response.json();
118-
}
119-
```
120-
121-
---
122-
123-
### B6 — Missing null guards in `normaliseCard` (`src/firestore.js:115–116`)
124-
125-
**Severity: Medium**
126-
127-
`data.column.id` and `data.owner.id` are accessed directly without checking that `column` or `owner` exist. If a Firestore document is missing either field (due to a partial write, schema migration, or corrupted data), this throws inside the `onSnapshot` callback and breaks all card subscriptions silently.
128-
129-
**Fix:**
130-
131-
```js
132-
column: data.column?.id ?? null,
133-
owner: data.owner?.id === get(uid),
134-
```
135-
136-
---
137-
138-
### B7 — Private Firestore SDK field access (`src/firestore.js:124`)
139-
140-
**Severity: Medium**
141-
142-
```js
143-
document._document.createTime.timestamp.seconds * 1000
144-
```
145-
146-
`_document` is an internal, undocumented Firestore SDK field. It can silently change or disappear on any SDK update. The fallback is also non-obvious.
147-
148-
**Fix:** Use `Date.now()` as the fallback rather than reaching into private internals:
149-
150-
```js
151-
created_at: new Date(data?.created_at?.toMillis() ?? Date.now()),
152-
```
153-
154-
---
155-
156-
### B8 — Race condition in async derived store (`src/store.js:7076`)
157-
158-
**Severity: Medium**
159-
160-
```js
161-
export const passwordValid = derived(
162-
[board, password],
163-
([$board, $password], set) => {
164-
checkBoardPassword($board, $password).then(set);
165-
},
166-
null,
167-
);
168-
```
169-
170-
If `board` or `password` changes rapidly, multiple async calls can be in-flight simultaneously. Whichever resolves last wins — which may not be the most recent call. This can leave `passwordValid` set to a stale result.
171-
172-
**Fix:** Cancel the previous promise by tracking a generation counter:
173-
174-
```js
175-
export const passwordValid = derived(
176-
[board, password],
177-
([$board, $password], set) => {
178-
let cancelled = false;
179-
checkBoardPassword($board, $password).then((v) => { if (!cancelled) set(v); });
180-
return () => { cancelled = true; };
181-
},
182-
null,
183-
);
184-
```
185-
186-
---
187-
1887
## Security
1898

1909
### S1 — Weak encryption: no key derivation, no authentication (`src/encryption.js:1–20`)

src/App.svelte

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,19 @@
1717
);
1818
1919
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');
20-
prefersDarkScheme.addEventListener('change', (e) => {
20+
const handleSchemeChange = (e) => {
2121
const systemPreference = e.matches;
2222
const appPreference = window.localStorage.getItem('darkModePreference');
23-
24-
// Update color theme if the user hasn't set an app preference
2523
if (appPreference) {
2624
$darkMode = appPreference === 'dark';
2725
} else {
2826
$darkMode = systemPreference;
2927
}
30-
});
28+
};
29+
prefersDarkScheme.addEventListener('change', handleSchemeChange);
3130
return () => {
3231
unsubscribeDarkModeChange();
33-
prefersDarkScheme.removeEventListener('change', darkModeChangeListener);
32+
prefersDarkScheme.removeEventListener('change', handleSchemeChange);
3433
};
3534
});
3635
</script>

src/Board.svelte

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,8 @@
5757
copy: true,
5858
moves: (el) => el.dataset.drag !== 'false',
5959
accepts: (el, target) => {
60-
return (
61-
target.dataset.rankId !==
62-
$cards.find((c) => c.id === el.dataset.cardId).column
63-
);
60+
const card = $cards.find((c) => c.id === el.dataset.cardId);
61+
return card != null && target.dataset.rankId !== card.column;
6462
},
6563
});
6664
@@ -78,6 +76,7 @@
7876
const rankId = target.dataset.rankId;
7977
const cardId = el.dataset.cardId;
8078
const card = $cards.find((c) => c.id === cardId);
79+
if (!card) return;
8180
const originalRankId = card.column;
8281
8382
el.parentNode.removeChild(el);
@@ -129,7 +128,13 @@
129128
}
130129
131130
onMount(async () => {
132-
const b = await getBoard(boardId);
131+
let b;
132+
try {
133+
b = await getBoard(boardId);
134+
} catch {
135+
navigate('/not-found');
136+
return;
137+
}
133138
134139
if (b.error == 'Not Found') {
135140
navigate('/not-found');
@@ -150,10 +155,8 @@
150155
let previousBoard = { ...$board };
151156
if ($board.owner || $board.open_permission) {
152157
unsubscribeLocalBoard = board.subscribe((b) => {
153-
try {
154-
if (!compareBoards(previousBoard, b)) updateBoard(b);
155-
} catch (err) {
156-
error('error.updating_settings', err);
158+
if (!compareBoards(previousBoard, b)) {
159+
updateBoard(b).catch((err) => error('error.updating_settings', err));
157160
}
158161
previousBoard = { ...b };
159162
});

src/api.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ async function request(input, init) {
1414
}
1515

1616
async function requestJson(input, init) {
17-
return (await fetch(input, init)).json();
17+
const response = await fetch(input, init);
18+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
19+
return response.json();
1820
}
1921

2022
export async function createAuthToken() {

src/components/Menu.svelte

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script>
22
import ClipboardJS from 'clipboard';
3+
import { onMount } from 'svelte';
34
import { _ } from 'svelte-i18n';
45
import { fly } from 'svelte/transition';
56
import {
@@ -39,7 +40,11 @@
3940
if ($board.data?.timer_end_at != null) showTimer = true;
4041
});
4142
42-
new ClipboardJS('button');
43+
let clipboard;
44+
onMount(() => {
45+
clipboard = new ClipboardJS('[data-clipboard-text]');
46+
return () => clipboard.destroy();
47+
});
4348
4449
const preventDefault = (e) => {
4550
e.preventDefault();

src/firestore.js

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,17 +112,14 @@ function normaliseCard(document) {
112112
...data,
113113
id: document.id,
114114
votes: data?.votes?.length ?? 0,
115-
column: data.column.id,
116-
owner: data.owner.id === get(uid),
115+
column: data.column?.id ?? null,
116+
owner: data.owner?.id === get(uid),
117117
voted:
118118
(data?.votes ?? []).find((voteDoc) => voteDoc.id === get(uid)) !==
119119
undefined,
120120
reactions,
121121
reacted,
122-
created_at: new Date(
123-
data?.created_at?.toMillis() ?? // Added 16 Mar 2023
124-
document._document.createTime.timestamp.seconds * 1000 // Bit of a hack
125-
),
122+
created_at: new Date(data?.created_at?.toMillis() ?? Date.now()),
126123
};
127124
}
128125

src/store.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,13 @@ export const colors = derived(colorMode, ($colorMode) => Colors[$colorMode]);
7070
export const passwordValid = derived(
7171
[board, password],
7272
([$board, $password], set) => {
73-
checkBoardPassword($board, $password).then(set);
73+
let cancelled = false;
74+
checkBoardPassword($board, $password).then((v) => {
75+
if (!cancelled) set(v);
76+
});
77+
return () => {
78+
cancelled = true;
79+
};
7480
},
7581
null
7682
);

0 commit comments

Comments
 (0)