DEV Community

Jibola Timothy Kolawole
Jibola Timothy Kolawole

Posted on

I Thought I Understood SPAs. A Blank Screen Proved Me Wrong.

I had AI help me structure the router. It looked clean. The logic flowed. I shipped it.

Then I opened the browser and saw a blank white page.

No error. No warning. Just silence. πŸ¦—

🧱 The Blank Screen Problem

Here's what the router was supposed to do β€” and what it was actually doing.

The architecture was simple enough on paper: index.html holds the landing page. A separate #app-container div catches everything else β€” auth screens, dashboards. The router listens to the URL and decides what to render where.

async _render(path) {
  // ...identify path...
  // ...check auth...
  // ...show loading...
  // ...render view...
}
Enter fullscreen mode Exit fullscreen mode

Logical. Clean. Broken in four different ways simultaneously. πŸ™ƒ

πŸ› Bug #1: The export That Silently Killed a Module

Inside dashCustomer.js, the entire dashboard view was wrapped in a DOMContentLoaded callback:

document.addEventListener('DOMContentLoaded', () => {
  const container = document.getElementById('app');

  export const CustomerDashView = {   // πŸ’€ fatal syntax error
    render(container) {
      // 400+ lines of dashboard code
    }
  };
});
Enter fullscreen mode Exit fullscreen mode

export must be at the top level of a module. You cannot export from inside a function or callback. This is a hard syntax rule in ES modules.

The browser's response to this? It silently refuses to parse the file. 🀐 No module loads. The router tries to import CustomerDashView, gets undefined, tries to call undefined.render(), and throws an error so far downstream from the real cause that you spend twenty minutes looking at the wrong file.

The fix was just two lines β€” remove the wrapper βœ‚οΈ:

// βœ… No DOMContentLoaded. No wrapper. Just this:
export const CustomerDashView = {
  render(container) {
    // dashboard code
  }
};
Enter fullscreen mode Exit fullscreen mode

πŸ› Bug #2: Calling appendChild on a Plain Object

With the syntax error fixed, something rendered. Then it immediately crashed with:

❌ TypeError: Failed to execute 'appendChild' on 'Element': 
   parameter 1 is not of type 'Node'.
Enter fullscreen mode Exit fullscreen mode

The router was doing this:

const view = await routeLoader();
appContainer.appendChild(view);   // ❌ wrong assumption
Enter fullscreen mode Exit fullscreen mode

The view files don't return DOM nodes. They return plain objects with a .render() method:

export const CustomerDashView = {
  render(container) {
    container.innerHTML = `...`;
    // attach logic, load data, etc.
  }
};
Enter fullscreen mode Exit fullscreen mode

appendChild expects a DOM element. 🧩 Passing it a plain JavaScript object throws immediately. The fix required understanding what kind of thing the view actually was:

const view = await routeLoader();
if (view && typeof view.render === 'function') {
  view.render(appContainer);       // βœ… for object-style views
} else if (view instanceof Node) {
  appContainer.appendChild(view);  // βœ… for views that return DOM nodes
}
Enter fullscreen mode Exit fullscreen mode

πŸ› Bug #3: The Container That Was Always Hidden

After both fixes, the router was loading the view. The data was fetching. The JavaScript was running. The page was still blank. 😢

The #app-container in index.html had this:

<div id="app-container" style="display:none"></div>
Enter fullscreen mode Exit fullscreen mode

The router was rendering an entire working dashboard into an invisible box. πŸ“¦ Nobody had written the line to show it.

No error. No warning. The app was working perfectly, invisibly. πŸ‘»

// 🀦 This single line was simply missing:
if (appContainer) appContainer.style.display = '';
Enter fullscreen mode Exit fullscreen mode

πŸ› Bug #4: A Variable Used Before It Was Declared

While adding the visibility fix, I introduced a new one:

// πŸ” Early in the function:
if (appContainer) appContainer.style.display = '';  // πŸ’₯ ReferenceError

// ...15 lines of other logic...

// πŸ‘‡ Only declared down here:
const appContainer = document.getElementById('app-container');
Enter fullscreen mode Exit fullscreen mode

const doesn't hoist. ⚠️ I'd moved the visibility toggle above the declaration during refactoring and didn't notice. The variable was being referenced before it existed.

The fix was simply to move the const appContainer declaration to the top of the function β€” before the first point it was used. πŸ”Ό

πŸ’¬ The Moment That Actually Mattered

By the time I'd found and fixed these bugs, it was late. I was frustrated. I messaged someone and said something like: "I've been using AI to write this code and honestly I understand most of it but maybe not all of it." 😬

The response stopped me:

"AI writes code you don't understand. It works. Then it breaks. You don't know why. You ask AI to fix it. It breaks something else. You're stuck in a loop with no exit β€” which is exactly what happened with your routing bug tonight." πŸ”

That was accurate. That's exactly what had happened.

But then came the part I actually needed to hear πŸ‘‡:

"A basic SPA is actually just 3 ideas:
πŸ“„ One HTML file with a div that acts as a container.
πŸ”„ JavaScript that listens to the URL and swaps content into that container.
πŸ”— A way to change the URL without reloading the page using history.pushState.
That's it. Everything else is just complexity on top of those 3 things."

I'd been working with a router that had auth guards, lazy loading, body class management, header visibility toggling, loading spinners, view mounting strategies. And I hadn't been able to debug it confidently because I'd lost sight of what a router fundamentally is. πŸ—ΊοΈ

Here it is in its purest form πŸ‘‡:

<div id="app"></div>

<script>
  function render(path) {
    const app = document.getElementById('app');
    if (path === '/')
      app.innerHTML = '<h1>Home</h1>';
    else if (path === '/about')
      app.innerHTML = '<h1>About</h1>';
    else
      app.innerHTML = '<h1>Not Found</h1>';
  }

  document.querySelectorAll('a').forEach(a => {
    a.addEventListener('click', e => {
      e.preventDefault();
      history.pushState({}, '', a.href);
      render(location.pathname);
    });
  });

  window.addEventListener('popstate', () => render(location.pathname));

  render(location.pathname); // πŸš€ boot
</script>
Enter fullscreen mode Exit fullscreen mode

That's the whole contract. πŸ“œ Every SPA framework, every custom router, every react-router or vue-router you've ever used is this β€” with abstractions layered on top.

Once I saw that, the bugs from earlier became obvious in a different way πŸ’‘:

  • πŸ”΄ The export error broke the module so no view could load
  • πŸ”΄ The appendChild error meant the router didn't understand what a "view" was
  • πŸ”΄ The display:none bug meant the container never became part of the visible page
  • πŸ”΄ The hoisting bug meant the container reference didn't exist when the code needed it

Each bug was a violation of one of those three core ideas. And I couldn't see that while I was staring at 200 lines of code I hadn't fully internalized. πŸ˜”

🎯 The Honest Takeaway

I'm not writing this to tell you to stop using AI tools. I use them constantly. They're genuinely useful for scaffolding, for boilerplate, for getting a working first version fast. πŸ€–

But there's a specific failure mode that's easy to fall into: you ask AI to write something, it works, you move on, and you never actually learn what it does. Then when it breaks β€” and it will break β€” you're standing in front of a blank screen with no map. πŸ—ΊοΈβŒ

The process that actually works, the one I ended up doing last night whether I intended to or not:

  1. πŸ€– Let AI write the first version
  2. πŸ‘€ Read every line. Ask "what does this do?"
  3. πŸ™‹ Ask AI to explain anything you don't recognise
  4. πŸ—‘οΈ Delete a piece and try to rewrite it yourself
  5. πŸ’₯ Break something deliberately and fix it yourself

Step 5 is the one that sticks. πŸ“Œ I found four bugs last night under pressure, in code I was uncertain about, and understood every fix by the time I was done. That understanding is mine now. It didn't come from reading documentation or watching a tutorial.

It came from the blank screen. πŸ–₯️

βš™οΈ What I'm Doing Differently

Every time I use a significant piece of code I didn't write from scratch, I now ask myself one question before moving on:

πŸ’­ "Can I explain what breaks if I delete this line?"

If the answer is no, that's a gap. Not a crisis β€” gaps are fine and normal. πŸ™‚ But I write it down. I come back to it. I poke at it until I can answer the question.

The router works now. βœ… The dashboards load. βœ… But more importantly, I know exactly why β€” and I know what I'd look at first if the screen went blank again.

🚚 I'm building OffScape Logistics, a delivery platform for businesses in Lagos and Ibadan with real-time GPS tracking, escrow payments, and role-based dashboards. If you're building something similar or just want to talk about SPA architecture, I'm on Twitter Creative @astra9cc

JavaScript, #WebDevelopment, #SPA, #Debugging, #LearningtoCode, #BuildinginPublic

Top comments (0)