Aller au contenu principal

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:

  1. Both keys displayed correctly initially, showing their name, provider, value, etc.
  2. 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.

  1. Saving Date Objects to localStorage:

    • When an API key is tested, the testApiKey function (not shown) updates apiKey.lastTested to a Date object (e.g., new Date()).
    • When the apiKeys array is saved to localStorage (likely using JSON.stringify(this.apiKeys)), JSON.stringify() automatically converts Date objects into their ISO 8601 string representation (e.g., "2023-10-27T10:00:00.000Z"). This is standard and expected behavior for JSON.
  2. Loading Date Strings from localStorage:

    • The loadApiKeys() function retrieves the saved data using localStorage.getItem('llm-api-keys').
    • It then calls JSON.parse(saved). Crucially, JSON.parse() does not automatically convert these ISO date strings back into Date objects. So, after JSON.parse(), apiKey.lastTested for any tested key becomes a string, not a Date object, even though your LLMApiKey interface might expect it to be a Date.
  3. Template Rendering & Type Error:

    • In the HTML template, there's a line: {{ formatTestTime(apiKey.lastTested) }}.
    • The formatTestTime function is designed to take a Date object and format it for display. It likely attempts to call Date methods (e.g., .getFullYear(), .toLocaleString(), etc.) on its input.
    • The Bug Trigger: For the tested API keys, apiKey.lastTested is now a string. When formatTestTime tries to call a Date method on this string, it results in a runtime TypeError (e.g., "TypeError: apiKey.lastTested.getFullYear is not a function").
  4. Why Only the Second Key?

    • If the first key was never tested, apiKey.lastTested would be undefined (or null) initially and after loading. The @if (apiKey.lastTested) check would be false, and the problematic <span> (and thus formatTestTime) would simply not be evaluated, preventing the error.
    • The second key, being tested, had apiKey.lastTested set. When loaded, it became a string, triggering the TypeError 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.

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 the apiKey.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.
    • (ngModelChange)="updateApiKey(...)" is still used to trigger your persistence logic (saving to localStorage), 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 internal apiKey.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.
  • 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:
    1. The code now first parses the JSON string into an array of objects (keys).
    2. It then uses the map() method to iterate over each key in this array.
    3. For each key, it creates a new object, spreading all existing properties (...key).
    4. 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. The Date constructor is capable of parsing ISO 8601 strings back into actual Date objects.
      • If key.lastTested was originally undefined or null (for an untested key), it remains undefined.
  • Impact: By transforming the lastTested strings back into proper Date objects, the this.apiKeys array now contains data that perfectly matches the LLMApiKey interface's expectation. When the template calls formatTestTime(apiKey.lastTested), it now receives a valid Date object, preventing the TypeError 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.