There is a special frustration reserved for bugs where the logic is correct but the output is wrong. You have verified every intermediate step. The data flows through the system accurately. The computation produces the right values. And yet the final result, the thing the user actually sees, is subtly broken -- not because of a logic error but because of a rendering defect.
On January 7, 2026, we had just fixed the None handling bug that unblocked FLIN's temporal model. Four of our 27 temporal integration tests were now passing. The remaining 23 were failing, and at first glance, the failures looked like temporal logic errors. But they were not. The temporal values were all correct. The bug was in how whitespace was handled between static text and dynamic bindings in HTML output.
The Symptom
Consider this FLIN template:
<div>
Current: {user.name}<br>
Previous: {previous.name}
</div>A developer writing this expects the output to read "Current: Alice Smith" with a space between the colon and the name. The test assertions reflected this expectation:
assert_output_contains(&output, "Current: Alice Smith");But the actual HTML output was:
<div>Current:<span data-flin-bind="user.name">Alice Smith</span><br>
Previous:<span data-flin-bind="previous.name">Alice</span></div>No space before the tag. "Current:" was jammed directly against the dynamic binding. The temporal values "Alice Smith" and "Alice" were perfectly correct -- the bug had nothing to do with the temporal model. It was a rendering defect that made correct values appear wrong in test assertions.
The Root Cause
The problem lived in FLIN's lexer, specifically in how it handled text nodes during view mode scanning. When the lexer encountered text content in a template, it called .trim() on the entire text node:
// src/lexer/scanner.rs:1413 -- BEFORE
let trimmed = text.trim();
if !trimmed.is_empty() {
self.add_token(TokenKind::Text(trimmed.to_string()));
}The .trim() method removes all leading and trailing whitespace from a string. For the text node "Current: ", this removed the trailing space, producing "Current:". The space that should have appeared between the static text and the dynamic binding was silently consumed by the lexer.
This was an intentional design choice that worked well for multi-line templates. Consider:
<div>
Hello World
</div>Without trimming, the text node would include the newline and indentation: "\n Hello World\n". The .trim() call correctly removed this formatting whitespace, producing the clean text "Hello World." The problem was that .trim() is a blunt instrument -- it removes all whitespace indiscriminately, including whitespace that carries semantic meaning.
Inline vs. Multi-line Whitespace
The key insight was that whitespace in templates has two different meanings depending on context:
Indentation whitespace is formatting. It exists because the developer indented their template for readability. It should be removed:
<div>
Hello World // "\n Hello World\n" -> "Hello World"
</div>Inline whitespace is content. It exists because the developer wants a space between elements. It should be preserved:
<div>Current: {user.name}</div> // "Current: " should keep trailing spaceThe difference is whether the text contains newlines. Multi-line text nodes are indentation-formatted and should be trimmed. Single-line text nodes are inline content and should be preserved exactly.
The Smart Trim Algorithm
We replaced the blanket .trim() with a smart_trim_text() function that handles both cases:
fn smart_trim_text(&self, text: &str) -> String {
if !text.contains('\n') {
// Inline text -- preserve all whitespace
return text.to_string();
}// Multi-line text -- trim indentation let mut result = text.to_string();
// Remove leading newlines and their following indentation loop { let before = result.len(); result = result.trim_start_matches('\n') .trim_start_matches('\r') .to_string(); if result.starts_with(' ') || result.starts_with('\t') { result = result.trim_start().to_string(); } if result.len() == before { break; } }
// Remove trailing newlines and their preceding indentation loop { let before = result.len(); result = result.trim_end_matches('\n') .trim_end_matches('\r') .to_string(); if result.ends_with(' ') || result.ends_with('\t') { result = result.trim_end().to_string(); } if result.len() == before { break; } }
result } ```
The algorithm is simple: if the text contains no newlines, return it unchanged. If it contains newlines, iteratively remove leading and trailing newline-plus-indentation sequences. This preserves inline spaces while removing formatting indentation.
Examples
| Input | Output | Reason |
|---|---|---|
"Current: " | "Current: " | No newlines, preserve all |
"\n Hello\n " | "Hello" | Has newlines, trim indentation |
"Hello " | "Hello " | No newlines, preserve trailing space |
" inline " | " inline " | No newlines, preserve all |
Updating Test Assertions
With the whitespace fix in place, the HTML output now correctly preserved the space:
<div>Current: <span data-flin-bind="user.name">Alice Smith</span></div>However, the test assertions also needed updating. Many tests checked for substrings like "Current: Alice Smith", but the actual HTML contained a tag between the label and the value. We restructured the assertions to check for the label and value separately:
// BEFORE: Failed because of HTML span tag
assert_output_contains(&output, "Current: Alice Smith");// AFTER: Works with HTML structure assert_output_contains(&output, "Current:"); assert_output_contains(&output, "Alice Smith"); ```
This approach is more robust because it tests what matters -- that the label and value are both present -- without being brittle about the exact HTML structure between them.
The Results
The impact was immediate and dramatic:
| Metric | Before | After |
|---|---|---|
| Temporal tests passing | 4/27 (15%) | 11/27 (41%) |
| Library tests | 1,010/1,010 | 1,010/1,010 |
| Whitespace preserved | No | Yes |
Seven additional temporal tests started passing, not because we changed any temporal logic, but because the rendering now correctly displayed the temporal values that had been correct all along.
The remaining 16 failing tests were genuine logic issues -- unimplemented features like .history property access, conditional rendering with temporal values, and edge cases for out-of-range temporal access. These were real work items, not rendering artifacts.
Why This Bug Matters
This bug illustrates a fundamental tension in template rendering: the desire for clean output conflicts with the need for semantic fidelity.
Every template engine faces this choice. Some engines preserve all whitespace verbatim, producing output with unnecessary indentation and blank lines. Others aggressively strip whitespace, occasionally removing meaningful spaces. The right approach is context-sensitive: remove formatting whitespace while preserving content whitespace.
FLIN's smart_trim_text() function embodies this context-sensitive approach. By using newline presence as the discriminator, it correctly handles both cases with a simple heuristic. Multi-line text nodes are formatted and should be trimmed. Single-line text nodes are content and should be preserved.
The lesson extends beyond whitespace handling. When a rendering layer transforms data for display, it must distinguish between formatting artifacts and meaningful content. Applying a uniform transformation -- whether it is trimming, escaping, encoding, or normalizing -- inevitably destroys some information. The art is in knowing which information to preserve and which to discard.
The Chain of Bugs
This was the third bug in a chain that blocked the temporal model:
1. None handling bug (Session 070): Property access on None threw TypeError instead of propagating. Fixed with 10 lines in the VM.
2. Version tracking bug (Session 073): Entity versions were not synced from database to VM after saves, causing temporal access to compute wrong target versions. Fixed by fetching entity data back after save.
3. Whitespace rendering bug (Session 074): Lexer trimmed inline spaces between static text and dynamic bindings. Fixed with smart_trim_text().
Each bug was independent. Each had a different root cause in a different component (VM, database sync, lexer). Yet together they formed a chain that made the temporal model appear completely broken when, in fact, the temporal logic had been working correctly since Session 070. The fixes were cumulative -- each one brought more tests from failing to passing, gradually revealing that the foundation was solid and only the surface needed repair.
---
This is Part 159 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.
Series Navigation: - [158] The None Handling Bug - [159] The HTML Whitespace Rendering Bug (you are here) - [160] When the VM Deadlocked on Entity Creation