Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 36 additions & 13 deletions packages/web/src/components/checklist/LocalChecklistView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import localChecklistsStore from '@/stores/localChecklistsStore';
import { getChecklistTypeFromState, scoreChecklistOfType } from '@/checklist-registry';
import { IoChevronBack } from 'solid-icons/io';
import ScoreTag from '@/components/checklist/ScoreTag.jsx';
import { createLocalAdapterFactories } from '@/components/checklist/common/LocalTextAdapter.js';

export default function LocalChecklistView() {
const params = useParams();
Expand All @@ -28,6 +29,29 @@ export default function LocalChecklistView() {
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal(null);

// Debounced save function always saves the full current checklist state
// to avoid race conditions where rapid partial updates could overwrite each other.
// Ignores arguments and reads checklist() when it fires to get the latest merged state.
// eslint-disable-next-line solid/reactivity
const debouncedSave = debounce(async () => {
try {
const current = checklist();
if (current) {
await updateChecklist(params.checklistId, current);
}
} catch (err) {
console.error('Error saving checklist:', err);
}
}, 500);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition when navigating between checklists loses or corrupts data

High Severity

The refactored debouncedSave reads params.checklistId when it fires, not when scheduled. When navigating from checklist A to B, the pending save isn't cancelled in the createEffect. If the save fires during loading, it may save A's data to B's ID (data corruption). If it fires after loading completes, A's unsaved changes are lost. The old code captured checklistId at schedule time, avoiding this issue.

Additional Locations (1)

Fix in Cursor Fix in Web


// Create adapter factories for text fields
const { getRob2Text, getQuestionNote, getRobinsText, clearCache } = createLocalAdapterFactories(
// eslint-disable-next-line solid/reactivity
() => checklist(),
setChecklist,
debouncedSave,
);

// Load the checklist and PDF on mount
createEffect(() => {
const checklistId = params.checklistId;
Expand Down Expand Up @@ -55,6 +79,8 @@ export default function LocalChecklistView() {
return;
}

// Clear adapter cache when loading new data to prevent stale values
clearCache();
setChecklist(loaded);

// Load saved PDF if exists
Expand All @@ -75,30 +101,24 @@ export default function LocalChecklistView() {
})();
});

// Debounced save function
const debouncedSave = debounce(async (checklistId, updates) => {
try {
await updateChecklist(checklistId, updates);
} catch (err) {
console.error('Error saving checklist:', err);
}
}, 500);

// Cleanup on unmount
onCleanup(() => {
debouncedSave.clear();
clearCache();
});

// Handle updates from the AMSTAR2Checklist component
// Handle updates from checklist components (answers, judgements, etc.)
const handleUpdate = updates => {
// Optimistically update local state
setChecklist(prev => {
if (!prev) return prev;
return { ...prev, ...updates };
});

// Debounce the save to IndexedDB
debouncedSave(params.checklistId, updates);
// Clear adapter cache to ensure text fields sync with new state
clearCache();

// Trigger debounced save - it will read checklist() when it fires
debouncedSave();
};

// Handle PDF change
Expand Down Expand Up @@ -209,6 +229,9 @@ export default function LocalChecklistView() {
onPdfChange={handlePdfChange}
onPdfClear={handlePdfClear}
allowDelete={true}
getQuestionNote={getQuestionNote}
getRob2Text={getRob2Text}
getRobinsText={getRobinsText}
/>
</Show>
</Show>
Expand Down
213 changes: 213 additions & 0 deletions packages/web/src/components/checklist/common/LocalTextAdapter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/**
* LocalTextAdapter - Y.Text-compatible adapter for local checklist comments
*
* Mimics Y.Text interface to enable NoteEditor to work with plain strings
* in local checklists without requiring full Yjs infrastructure.
*/

/**
* Creates adapter factory functions for local checklist text fields.
* Encapsulates all the path resolution and caching logic.
*
* @param {Function} getChecklist - Getter for current checklist state
* @param {Function} updateState - Function to update checklist state: (updater: (prev) => next) => void
* @param {Function} save - Function to trigger debounced persistence (reads current state when it fires)
* @returns {Object} Factory functions and cache control
*/
export function createLocalAdapterFactories(getChecklist, updateState, save) {
const cache = new Map();

function getOrCreateAdapter(path, getValue, getUpdatedState) {
if (cache.has(path)) {
return cache.get(path);
}

const currentValue = getValue(getChecklist());

const adapter = new LocalTextAdapter(currentValue, newValue => {
updateState(prev => {
if (!prev) return prev;
const updated = getUpdatedState(prev, newValue);
// Trigger save after state is updated - save() reads current state when it fires
save();
return updated;
});
});

cache.set(path, adapter);
return adapter;
}

// ROB2: domain comments and preliminary text fields
function getRob2Text(sectionKey, fieldKey, questionKey) {
let path, getValue, getUpdatedState;

if (sectionKey.startsWith('domain') && questionKey) {
path = `${sectionKey}.${questionKey}.comment`;
getValue = cl => cl?.[sectionKey]?.answers?.[questionKey]?.comment || '';
getUpdatedState = (prev, newValue) => ({
...prev,
[sectionKey]: {
...prev[sectionKey],
answers: {
...prev[sectionKey]?.answers,
[questionKey]: {
...prev[sectionKey]?.answers?.[questionKey],
comment: newValue,
},
},
},
});
} else if (sectionKey === 'preliminary') {
path = `preliminary.${fieldKey}`;
getValue = cl => cl?.preliminary?.[fieldKey] || '';
getUpdatedState = (prev, newValue) => ({
...prev,
preliminary: {
...prev.preliminary,
[fieldKey]: newValue,
},
});
} else {
return null;
}

return getOrCreateAdapter(path, getValue, getUpdatedState);
}

// AMSTAR2: question notes
function getQuestionNote(questionKey) {
const path = `notes.${questionKey}`;
const getValue = cl => cl?.notes?.[questionKey] || '';
const getUpdatedState = (prev, newValue) => ({
...prev,
notes: {
...prev.notes,
[questionKey]: newValue,
},
});

return getOrCreateAdapter(path, getValue, getUpdatedState);
}

// ROBINS-I: domain comments, sectionB comments, and section text fields
function getRobinsText(sectionKey, fieldKey, questionKey) {
let path, getValue, getUpdatedState;

if (sectionKey.startsWith('domain') && questionKey) {
path = `${sectionKey}.${questionKey}.comment`;
getValue = cl => cl?.[sectionKey]?.answers?.[questionKey]?.comment || '';
getUpdatedState = (prev, newValue) => ({
...prev,
[sectionKey]: {
...prev[sectionKey],
answers: {
...prev[sectionKey]?.answers,
[questionKey]: {
...prev[sectionKey]?.answers?.[questionKey],
comment: newValue,
},
},
},
});
} else if (sectionKey === 'sectionB' && questionKey) {
path = `sectionB.${questionKey}.comment`;
getValue = cl => cl?.sectionB?.[questionKey]?.comment || '';
getUpdatedState = (prev, newValue) => ({
...prev,
sectionB: {
...prev.sectionB,
[questionKey]: {
...prev.sectionB?.[questionKey],
comment: newValue,
},
},
});
} else if (['sectionA', 'sectionC', 'sectionD', 'planning'].includes(sectionKey)) {
path = `${sectionKey}.${fieldKey}`;
getValue = cl => cl?.[sectionKey]?.[fieldKey] || '';
getUpdatedState = (prev, newValue) => ({
...prev,
[sectionKey]: {
...prev[sectionKey],
[fieldKey]: newValue,
},
});
} else {
return null;
}

return getOrCreateAdapter(path, getValue, getUpdatedState);
}

function clearCache() {
cache.clear();
}

return {
getRob2Text,
getQuestionNote,
getRobinsText,
clearCache,
};
}

export class LocalTextAdapter {
constructor(initialValue = '', onUpdate) {
this._value = initialValue;
this._onUpdate = onUpdate;
this._observers = new Set();

// Mock doc object with transact method (executes immediately)
this.doc = {
transact: fn => {
fn();
},
};
}

toString() {
return this._value;
}

get length() {
return this._value.length;
}

observe(callback) {
this._observers.add(callback);
}

unobserve(callback) {
this._observers.delete(callback);
}

delete(index, length) {
const before = this._value.substring(0, index);
const after = this._value.substring(index + length);
this._value = before + after;
this._notifyUpdate();
}

insert(index, text) {
const before = this._value.substring(0, index);
const after = this._value.substring(index);
this._value = before + text + after;
this._notifyUpdate();
}

_notifyUpdate() {
if (this._onUpdate) {
this._onUpdate(this._value);
}
this._observers.forEach(callback => callback());
}

// Allow external sync of value (e.g., when checklist reloads)
_syncValue(newValue) {
if (this._value !== newValue) {
this._value = newValue;
this._observers.forEach(callback => callback());
}
Comment on lines +199 to +211
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Biome lint error: avoid implicit returns in forEach callbacks.

Arrow expressions implicitly return, which triggers lint errors. Wrap callbacks in a block to avoid returning a value.

Proposed fix
-    this._observers.forEach(callback => callback());
+    this._observers.forEach(callback => {
+      callback();
+    });
@@
-      this._observers.forEach(callback => callback());
+      this._observers.forEach(callback => {
+        callback();
+      });
🧰 Tools
🪛 Biome (2.3.13)

[error] 202-202: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)


[error] 209-209: This callback passed to forEach() iterable method should not return a value.

Either remove this return or remove the returned value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
In `@packages/web/src/components/checklist/common/LocalTextAdapter.js` around
lines 198 - 210, The forEach callbacks in _notifyUpdate and _syncValue use
concise arrow bodies causing implicit returns (lint error); update both observer
iterations in methods _notifyUpdate and _syncValue to use block arrow functions
instead (e.g., change callback => callback() to callback => { callback(); }) so
the callbacks have an explicit block body and no implicit return.

}
}
8 changes: 1 addition & 7 deletions packages/workers/src/auth/config.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import { betterAuth } from 'better-auth';
import { createAuthMiddleware } from 'better-auth/api';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import {
genericOAuth,
magicLink,
twoFactor,
admin,
organization,
} from 'better-auth/plugins';
import { genericOAuth, magicLink, twoFactor, admin, organization } from 'better-auth/plugins';
import { oAuthRelay } from './oauth-relay';
import { stripe } from '@better-auth/stripe';
import Stripe from 'stripe';
Expand Down
Loading