The Case of the Disappearing API Key: A Data Type Desynchronization Bug
Problem Statement
A settings-modal
in an application allows users to save and test multiple API keys. The API keys are persisted using localStorage
.
The Bug: When a user saved two API keys:
- Both keys displayed correctly initially, showing their name, provider, value, etc.
- After testing both API keys, and then refreshing the page:
- The first API key would still display all its information correctly.
- The second API key would appear as a placeholder or block, with its value and test status information entirely missing. It seemed as if its data had vanished or couldn't be rendered.
Original Code Excerpts (Before the Fix)
The issue stemmed from two primary areas:
1. HTML Template (settings-modal.component.html
excerpt)
<div class="api-keys-list">
@if (apiKeys.length === 0) {
<div class="empty-state">
<p>No API keys configured</p>
</div>
} @else {
@for (apiKey of apiKeys; track apiKey.id) {
<div class="api-key-item" [class.active]="apiKey.isActive">
<div class="api-key-info">
<div class="api-key-header">
<span class="api-key-name">{{ apiKey.name }}</span>
<span class="api-key-provider">{{ apiKey.provider }}</span>
@if (apiKey.model && isGoogleGeminiProvider(apiKey.provider)) {
<span class="api-key-model">{{ getModelDisplayName(apiKey.model) }}</span>
}
</div>
<div class="api-key-value">
<input
type="password"
[value]="apiKey.apiKey" <!-- PROBLEM: One-way binding -->
(input)="updateApiKey(apiKey.id, 'apiKey', $event)"
placeholder="Enter API key"
class="api-key-input">
@if (apiKey.testStatus) {
<div class="test-status" [class]="'status-' + apiKey.testStatus">
@if (apiKey.testStatus === 'success') {
<span class="status-icon">✓</span> API key is valid
} @else if (apiKey.testStatus === 'error') {
<span class="status-icon">✗</span> API key failed
}
@if (apiKey.lastTested) {
<!-- PROBLEM: Expects Date object, but might get a string after localStorage load -->
<span class="test-time"> - Tested {{ formatTestTime(apiKey.lastTested) }}</span>
}
</div>
}
</div>
</div>
<div class="api-key-actions">
<button
class="test-button"
(click)="testApiKey(apiKey)"
[disabled]="testingKeys[apiKey.id]"
>
<!-- ... more template code ... -->
</button>
</div>
</div>
}
}
</div>
2. TypeScript Logic (settings-modal.component.ts
excerpt)
// ... interface LLMApiKey likely defined with lastTested: Date | undefined
// ... other component code
loadApiKeys() {
const saved = localStorage.getItem('llm-api-keys');
if (saved) {
this.apiKeys = JSON.parse(saved); // PROBLEM: Does not rehydrate Date objects
}
}
// ... other component code including formatTestTime(date: Date)
The Root Cause: Date Object Deserialization & Type Mismatch
The core of the bug lies in how JavaScript Date
objects are handled when serialized to and deserialized from localStorage
using standard JSON methods.
-
Saving
Date
Objects tolocalStorage
:- When an API key is tested, the
testApiKey
function (not shown) updatesapiKey.lastTested
to aDate
object (e.g.,new Date()
). - When the
apiKeys
array is saved tolocalStorage
(likely usingJSON.stringify(this.apiKeys)
),JSON.stringify()
automatically convertsDate
objects into their ISO 8601 string representation (e.g.,"2023-10-27T10:00:00.000Z"
). This is standard and expected behavior for JSON.
- When an API key is tested, the
-
Loading
Date
Strings fromlocalStorage
:- The
loadApiKeys()
function retrieves the saved data usinglocalStorage.getItem('llm-api-keys')
. - It then calls
JSON.parse(saved)
. Crucially,JSON.parse()
does not automatically convert these ISO date strings back intoDate
objects. So, afterJSON.parse()
,apiKey.lastTested
for any tested key becomes a string, not aDate
object, even though yourLLMApiKey
interface might expect it to be aDate
.
- The
-
Template Rendering & Type Error:
- In the HTML template, there's a line:
{{ formatTestTime(apiKey.lastTested) }}
. - The
formatTestTime
function is designed to take aDate
object and format it for display. It likely attempts to callDate
methods (e.g.,.getFullYear()
,.toLocaleString()
, etc.) on its input. - The Bug Trigger: For the tested API keys,
apiKey.lastTested
is now astring
. WhenformatTestTime
tries to call aDate
method on thisstring
, it results in a runtimeTypeError
(e.g., "TypeError: apiKey.lastTested.getFullYear is not a function").
- In the HTML template, there's a line:
-
Why Only the Second Key?
- If the first key was never tested,
apiKey.lastTested
would beundefined
(ornull
) initially and after loading. The@if (apiKey.lastTested)
check would be false, and the problematic<span>
(and thusformatTestTime
) would simply not be evaluated, preventing the error. - The second key, being tested, had
apiKey.lastTested
set. When loaded, it became a string, triggering theTypeError
upon rendering. - Angular's rendering process is robust, but a severe uncaught JavaScript error during interpolation or change detection for a specific element can cause that element, or subsequent elements within the same loop, to fail to render correctly or completely. This manifests as the "placeholder/block where value... is missing" symptom.
- If the first key was never tested,
The Solution (The Commit)
The provided commit addresses both the data type inconsistency and improves input field binding.
diff --git a/src/app/components/settings-modal/settings-modal.component.ts b/src/app/components/settings-modal/settings-modal.component.ts
index 2b92921..f99e095 100644
--- a/src/app/components/settings-modal/settings-modal.component.ts
+++ b/src/app/components/settings-modal/settings-modal.component.ts
@@ -56,8 +56,8 @@ export interface LLMApiKey {
<div class="api-key-value">
<input
type="password"
- [value]="apiKey.apiKey"
- (input)="updateApiKey(apiKey.id, 'apiKey', $event)"
+ [(ngModel)]="apiKey.apiKey" // Change 1
+ (ngModelChange)="updateApiKey(apiKey.id, 'apiKey', $event)" // Change 1
placeholder="Enter API key"
class="api-key-input">
@if (apiKey.testStatus) {
@@ -595,7 +595,11 @@ export class SettingsModalComponent implements OnInit {
loadApiKeys() {
const saved = localStorage.getItem('llm-api-keys');
if (saved) {
- this.apiKeys = JSON.parse(saved);
+ const keys = JSON.parse(saved) as LLMApiKey[];
+ this.apiKeys = keys.map(key => ({ // Change 2
+ ...key,
+ lastTested: key.lastTested ? new Date(key.lastTested) : undefined // Change 2
+ }));
}
}
How the Solution Works
The commit introduces two key changes:
1. Improved Input Binding: [(ngModel)]
- Before:
[value]="apiKey.apiKey"
combined with(input)="updateApiKey(...)"
[value]
is a one-way property binding. It sets the initial value of the input, but user changes to the input field do not automatically update theapiKey.apiKey
property in your component's model. You relied solely on the(input)
event handler to manually synchronize the model. While it works, it's less direct.
- After:
[(ngModel)]="apiKey.apiKey"
[(ngModel)]
(the "banana in a box" syntax) provides two-way data binding. This means:- Changes to
apiKey.apiKey
in the component automatically update the input's displayed value. - User input in the field automatically updates the
apiKey.apiKey
property in the component's model.
- Changes to
(ngModelChange)="updateApiKey(...)"
is still used to trigger your persistence logic (saving tolocalStorage
), which is good practice.
- Why it helps: While not the direct fix for the
Date
object problem, this change makes the input field more robust and ensures the component's internalapiKey.apiKey
model is always in sync with the user's view, preventing potential desynchronization issues that could lead to other subtle display bugs.
2. Date Object Rehydration in loadApiKeys()
- Before:
this.apiKeys = JSON.parse(saved);
- This directly assigned the parsed JSON, leaving
lastTested
as a string.
- This directly assigned the parsed JSON, leaving
- After:
const keys = JSON.parse(saved) as LLMApiKey[];
this.apiKeys = keys.map(key => ({
...key,
lastTested: key.lastTested ? new Date(key.lastTested) : undefined
})); - Why this is the direct fix for the reported bug:
- The code now first parses the JSON string into an array of objects (
keys
). - It then uses the
map()
method to iterate over eachkey
in this array. - For each
key
, it creates a new object, spreading all existing properties (...key
). - Crucially, it rehydrates the
lastTested
property:key.lastTested ? new Date(key.lastTested) : undefined
- This checks if
lastTested
exists (meaning it was present as a string in the saved JSON). - If it exists,
new Date(key.lastTested)
is called. TheDate
constructor is capable of parsing ISO 8601 strings back into actualDate
objects. - If
key.lastTested
was originallyundefined
ornull
(for an untested key), it remainsundefined
.
- The code now first parses the JSON string into an array of objects (
- Impact: By transforming the
lastTested
strings back into properDate
objects, thethis.apiKeys
array now contains data that perfectly matches theLLMApiKey
interface's expectation. When the template callsformatTestTime(apiKey.lastTested)
, it now receives a validDate
object, preventing theTypeError
and allowing Angular to correctly render all parts of the API key information for every item, resolving the "missing value" bug.
Conclusion
The bug was a classic example of data type desynchronization when persisting and retrieving complex objects (specifically those containing Date
objects) using localStorage
and basic JSON.parse
/JSON.stringify
. The fix directly addressed this by explicitly rehydrating the Date
strings back into Date
objects upon loading, thereby ensuring data integrity and preventing runtime errors during template rendering. The [(ngModel)]
improvement further solidifies the data binding for user input.