Summary
In the database browse view, opening a row in the Edit Row modal can leave the Save Changes button permanently un-clickable (disabled) while the JSON on screen looks fine. Closing the modal and re-opening it clears the condition. Reported by @Devin-Holland with a screenshot showing a valid-looking record (a versions array, cursor on line 20) and a greyed-out Save Changes next to an enabled Delete Row.
The user gets no indication of why Save is dead — no squiggle, no message, no toast, no tooltip.
Where
src/features/instance/databases/modals/EditTableRowModal.tsx
The button is gated on exactly two pieces of state:
disabled={!isValidJSON || isUpdateTableRecordsPending}
isValidJSON is written in only two places:
- Monaco's
onChange → setIsValidJSON(isRecordJsonProbablyValid(updatedValue)) (line ~153)
- The render-time reset block (lines 76–82), which sets it back to
true — but only when value changes, where value is derived purely from the fetched server record (searchByIdData?.data + syntheticAttributes).
value does not change while the user types. So once isValidJSON flips to false, the only things that can flip it back are another keystroke or the server record changing. Closing and re-opening the modal does neither: EditTableRowModal is rendered unconditionally by DatabaseTableView (only open toggles), so isValidJSON, updatedTableRecordData, and the Monaco buffer all survive a close/re-open of the same row.
Note also that isRecordJsonProbablyValid returns false for empty content, not just malformed content:
if (!content) { return false; }
Why there is no feedback (the regression)
af1fc3b (fix(browse): make record editors worker-free to end Monaco JSON OOM, #1370/#1499) moved both record editors to WORKER_FREE_JSON_LANGUAGE_ID and replaced the old marker-based gate with a main-thread parse.
Before that change, the gate was onValidate → Monaco JSON worker markers, so a disabled Save was always accompanied by a red squiggle on the offending line. The worker-free language has no validation markers at all — that is the point of it — so the gate lost its explanation but kept the disable.
Scope check: six submit buttons in the app gate on !isValidJSON. The three role modals (EditRoleModal, AddOrganizationRoleModal, EditOrganizationRoleModal) still use onValidate + the worker-backed JSON language, so they keep their squiggles and are not affected. The two affected ones are the record editors:
EditTableRowModal.tsx (reported)
AddTableRowModal.tsx — same pattern, same missing feedback (disabled={!addTableRecordData || !isValidJSON || isAddTableRecordsPending})
Reproduction candidates
Ranked, with the confidence I'd put on each. All are consistent with the code as read; none has been reproduced in a browser yet.
- Stale invalid buffer survives close/re-open of the same row. Type an edit that transiently breaks the JSON (e.g. delete a closing quote), then dismiss the modal without saving. React-query still holds the record under the same key, so
data and value are unchanged on re-open → the reset block does not fire → the editor still holds the malformed buffer and isValidJSON is still false. Because the malformed part can be scrolled off-screen and draws no marker, the visible portion looks perfectly valid. Most likely.
- The buffer really is invalid somewhere off-screen. The screenshot shows lines 15–24 of a 24-line record; lines 1–14 are not visible. With no markers, a single stray character anywhere in the document disables Save silently.
- Empty buffer. Select-all + delete (or a paste that momentarily empties the model) sets
isValidJSON = false via the if (!content) branch. Restoring content via a path that does not fire onChange would leave it stuck.
isUpdateTableRecordsPending stuck true. Would also disable the button, but onRecordUpdate has no onError, and a rejected mutation still clears isPending, so this looks unlikely — and it would not be cleared by closing the modal, since it lives in the parent.
Hypothesis 4 aside, all of these share one root cause: a hard disable with zero feedback, over state that outlives the modal.
Suggested fix
A. Stop disabling Save on validity (recommended). The authoritative, caught parse already exists in the click handler and already toasts on failure:
const parsed = tryParseRecordJson(updatedTableRecordData);
if (!parsed.ok) {
toast.error("This record isn't valid JSON — fix the syntax and try again.");
return;
}
Reducing the gate to disabled={isUpdateTableRecordsPending} makes the button always clickable and turns every failure mode above into an explicit message instead of a dead control. It also removes the reason isValidJSON needs to be reset correctly at all. Same change applies to AddTableRowModal.
B. Give the error a location. SyntaxError from JSON.parse carries a position; surfacing "line N, column M" (in the toast, or as a manually-set Monaco marker) restores what the JSON worker used to provide, without bringing the worker back. Worth doing alongside A.
C. Reset the editor on open, not only on record change. Independent of A/B, re-opening a row should show the stored record, not the previous unsaved draft. Today an abandoned edit silently persists into the next open of the same row.
While in here, one adjacent latent bug in the same handler:
if (!updatedTableRecordData) {
setIsModalOpen(false);
return;
}
If the record refetches while the user has unsaved edits, the reset block clears updatedTableRecordData back to undefined. Clicking Save then just closes the modal — no save, no warning, edits gone. Worth a guard in the same pass.
Summary
In the database browse view, opening a row in the Edit Row modal can leave the Save Changes button permanently un-clickable (disabled) while the JSON on screen looks fine. Closing the modal and re-opening it clears the condition. Reported by @Devin-Holland with a screenshot showing a valid-looking record (a versions array, cursor on line 20) and a greyed-out Save Changes next to an enabled Delete Row.
The user gets no indication of why Save is dead — no squiggle, no message, no toast, no tooltip.
Where
src/features/instance/databases/modals/EditTableRowModal.tsxThe button is gated on exactly two pieces of state:
isValidJSONis written in only two places:onChange→setIsValidJSON(isRecordJsonProbablyValid(updatedValue))(line ~153)true— but only whenvaluechanges, wherevalueis derived purely from the fetched server record (searchByIdData?.data+syntheticAttributes).valuedoes not change while the user types. So onceisValidJSONflips tofalse, the only things that can flip it back are another keystroke or the server record changing. Closing and re-opening the modal does neither:EditTableRowModalis rendered unconditionally byDatabaseTableView(onlyopentoggles), soisValidJSON,updatedTableRecordData, and the Monaco buffer all survive a close/re-open of the same row.Note also that
isRecordJsonProbablyValidreturnsfalsefor empty content, not just malformed content:Why there is no feedback (the regression)
af1fc3b (
fix(browse): make record editors worker-free to end Monaco JSON OOM, #1370/#1499) moved both record editors toWORKER_FREE_JSON_LANGUAGE_IDand replaced the old marker-based gate with a main-thread parse.Before that change, the gate was
onValidate→ Monaco JSON worker markers, so a disabled Save was always accompanied by a red squiggle on the offending line. The worker-free language has no validation markers at all — that is the point of it — so the gate lost its explanation but kept the disable.Scope check: six submit buttons in the app gate on
!isValidJSON. The three role modals (EditRoleModal,AddOrganizationRoleModal,EditOrganizationRoleModal) still useonValidate+ the worker-backed JSON language, so they keep their squiggles and are not affected. The two affected ones are the record editors:EditTableRowModal.tsx(reported)AddTableRowModal.tsx— same pattern, same missing feedback (disabled={!addTableRecordData || !isValidJSON || isAddTableRecordsPending})Reproduction candidates
Ranked, with the confidence I'd put on each. All are consistent with the code as read; none has been reproduced in a browser yet.
dataandvalueare unchanged on re-open → the reset block does not fire → the editor still holds the malformed buffer andisValidJSONis stillfalse. Because the malformed part can be scrolled off-screen and draws no marker, the visible portion looks perfectly valid. Most likely.isValidJSON = falsevia theif (!content)branch. Restoring content via a path that does not fireonChangewould leave it stuck.isUpdateTableRecordsPendingstuck true. Would also disable the button, butonRecordUpdatehas noonError, and a rejected mutation still clearsisPending, so this looks unlikely — and it would not be cleared by closing the modal, since it lives in the parent.Hypothesis 4 aside, all of these share one root cause: a hard disable with zero feedback, over state that outlives the modal.
Suggested fix
A. Stop disabling Save on validity (recommended). The authoritative, caught parse already exists in the click handler and already toasts on failure:
Reducing the gate to
disabled={isUpdateTableRecordsPending}makes the button always clickable and turns every failure mode above into an explicit message instead of a dead control. It also removes the reasonisValidJSONneeds to be reset correctly at all. Same change applies toAddTableRowModal.B. Give the error a location.
SyntaxErrorfromJSON.parsecarries a position; surfacing "line N, column M" (in the toast, or as a manually-set Monaco marker) restores what the JSON worker used to provide, without bringing the worker back. Worth doing alongside A.C. Reset the editor on open, not only on record change. Independent of A/B, re-opening a row should show the stored record, not the previous unsaved draft. Today an abandoned edit silently persists into the next open of the same row.
While in here, one adjacent latent bug in the same handler:
If the record refetches while the user has unsaved edits, the reset block clears
updatedTableRecordDataback toundefined. Clicking Save then just closes the modal — no save, no warning, edits gone. Worth a guard in the same pass.