Evaluate: Zero-Refactor SPA-Like Navigation with AJAX Content Swapping #3006
Zak Siddiqui opened 10 hours ago

Executive Summary

As OneDev continues to solidify its position as a highly capable Git management and CI/CD platform, the user interface's responsiveness plays a critical role in developer satisfaction. Currently, standard navigation triggers a full-page reload, leading to redundant rendering overhead and a visual "flash" that interrupts user focus.

I propose a high-ROI, low-risk frontend enhancement: an AJAX-driven navigation interceptor that achieves an instant, Single Page Application (SPA) feel. By leveraging Apache Wicket's built-in AJAX components to fetch and swap only the main content panel while keeping the global navigation shell intact, we can drastically improve perceived performance and visual ergonomics — without any architectural refactoring.


Motivation

  • Full-page reloads cause visual "flashes" that disrupt developer flow, especially when rapidly switching between projects, pull requests, issues, and CI pipelines.
  • Redundant server-side rendering of the global shell (header, sidebar, navigation tree) on every page navigation wastes CPU cycles and increases response latency.
  • No visual feedback during navigation leaves users wondering if the click registered — a loading indicator is essential for perceived performance.
  • Competitive expectations: Developers accustomed to modern SPA tools (GitHub, GitLab, Linear) expect fluid navigation as a baseline UX standard.

Proposed Solution

Introduce a lightweight JavaScript interceptor that:

  1. Intercepts all internal navigation clicks (anchors pointing to the same origin).
  2. Prevents the default full-page navigation.
  3. Shows a CSS-only top loading bar (similar to GitHub/YouTube).
  4. Fetches the target page via AJAX using Wicket's AbstractAjaxBehavior or AjaxLink.
  5. Extracts the main content panel from the server response.
  6. Swaps the content panel in the DOM without reloading the global shell.
  7. Hides the loading bar upon completion.
  8. Updates the browser URL via history.pushState to maintain back/forward button support.

Sequence Diagram

sequenceDiagram participant U as User participant B as Browser (UI) participant S as OneDev Server rect rgba(200, 200, 200, 0.1) Note over U,S: Current Architecture (Full Hard Reload) U->>B: Clicks Navigation Link (e.g. Issues -> Builds) B->>S: HTTP GET /builds Note over B,S: ❌ Entire DOM destroyed. Screen flashes blank. S-->>B: Returns Full HTML B->>B: Re-parses & repaints Header, Sidebar, and Main Content end rect rgba(100, 255, 100, 0.1) Note over U,S: Proposed Architecture (SPA Interceptor) U->>B: Clicks Navigation Link (e.g. Issues -> Builds) B->>S: Fetch API GET /builds (Background) Note over B: ✅ Header & Sidebar remain visible and stable. No flash. S-->>B: Returns Full HTML B->>B: Extracts <div id="main"> from response B->>B: Seamlessly swaps current #main with new #main B->>B: Re-executes scripts & Updates URL History end

Implementation Details

1. JavaScript Navigation Interceptor

// SPA Navigation Interceptor
(function() {
  'use strict';

  let loadingBar = null;

  function initLoadingBar() {
    if (!document.getElementById('spa-loading-bar')) {
      loadingBar = document.createElement('div');
      loadingBar.id = 'spa-loading-bar';
      document.body.appendChild(loadingBar);
    } else {
      loadingBar = document.getElementById('spa-loading-bar');
    }
  }

  function showLoadingBar() {
    initLoadingBar();
    loadingBar.classList.remove('finished');
    loadingBar.classList.add('loading');
  }

  function hideLoadingBar() {
    if (loadingBar) {
      loadingBar.classList.remove('loading');
      loadingBar.classList.add('finished');
      
      // Reset after animation completes
      setTimeout(() => {
        loadingBar.classList.remove('finished');
        loadingBar.style.width = '0';
      }, 600);
    }
  }

  function isInternalLink(anchor) {
    const href = anchor.getAttribute('href');
    if (!href || href.startsWith('#') || href.startsWith('javascript:') || href.startsWith('mailto:')) {
      return false;
    }
    const link = document.createElement('a');
    link.href = href;
    return link.hostname === window.location.hostname;
  }

  function isExcludedPath(href) {
    const excluded = ['/download', '/attachment', '/raw', '/blob', '/diff'];
    return excluded.some(path => href.includes(path));
  }

  function extractContentPanel(html) {
    const parser = new DOMParser();
    const doc = parser.parseFromString(html, 'text/html');
    const contentPanel = doc.querySelector('#content');
    return contentPanel ? contentPanel.innerHTML : null;
  }

  document.addEventListener('click', function(event) {
    const anchor = event.target.closest('a');
    if (!anchor) return;

    const href = anchor.getAttribute('href');
    if (!isInternalLink(anchor) || isExcludedPath(href)) return;

    // Skip if modifier keys are held (open in new tab, etc.)
    if (event.metaKey || event.ctrlKey || event.shiftKey) return;

    event.preventDefault();

    const targetUrl = anchor.href;

    // Show loading indicator
    showLoadingBar();

    // Update browser URL
    history.pushState({ path: targetUrl }, '', targetUrl);

    // Fetch the new page content
    fetch(targetUrl, {
      headers: {
        'X-Requested-With': 'XMLHttpRequest',
        'Accept': 'text/html, application/xhtml+xml'
      }
    })
    .then(response => response.text())
    .then(html => {
      const content = extractContentPanel(html);
      if (content) {
        const contentPanel = document.querySelector('#content');
        if (contentPanel) {
          contentPanel.innerHTML = content;
        }
      }
      hideLoadingBar();
    })
    .catch(() => {
      // Fallback to full navigation on error
      window.location.href = targetUrl;
    });
  });

  // Handle browser back/forward buttons
  window.addEventListener('popstate', function(event) {
    if (event.state && event.state.path) {
      showLoadingBar();
      fetch(event.state.path, {
        headers: {
          'X-Requested-With': 'XMLHttpRequest',
          'Accept': 'text/html, application/xhtml+xml'
        }
      })
      .then(response => response.text())
      .then(html => {
        const content = extractContentPanel(html);
        if (content) {
          const contentPanel = document.querySelector('#content');
          if (contentPanel) {
            contentPanel.innerHTML = content;
          }
        }
        hideLoadingBar();
      })
      .catch(() => {
        window.location.href = event.state.path;
      });
    }
  });
})();

2. CSS Loading Bar

/* SPA Navigation Loading Bar */
#spa-loading-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  background-color: #0366d6; /* Adjust to match OneDev's primary brand color */
  z-index: 9999;
  width: 0;
  opacity: 0;
  transition: width 0.4s ease, opacity 0.4s ease;
  pointer-events: none;
}

#spa-loading-bar.loading {
  opacity: 1;
  width: 70%; /* Faux progress while waiting for the server */
  transition: width 10s cubic-bezier(0.1, 0.8, 0.2, 1); /* Slow creep */
}

#spa-loading-bar.finished {
  width: 100%;
  opacity: 0;
  transition: width 0.2s ease, opacity 0.4s ease 0.2s;
}

3. Server-Side: Wicket AJAX Behavior

Add a WicketBehavior or AbstractAjaxBehavior that can be attached to the main content panel container. This behavior will handle AJAX requests for content swapping and return only the rendered panel markup.

// Example: AjaxContentSwappingBehavior.java
public class AjaxContentSwappingBehavior extends AbstractAjaxBehavior {
    @Override
    public void onRequest() {
        // Parse the target page from request parameters
        String targetPagePath = getRequest().getQueryParameters()
            .getParameterValue("targetPage").toString();
        
        // Render the target page's content panel only
        // (implementation depends on OneDev's Wicket page structure)
        getRequestCycle().setResponseMarkupType(MarkupType.HTML);
        // ... render and return only the content panel
    }
}

Exclusions and Fallbacks

  • File downloads, raw file views, and blob/diff pages are excluded and will continue to use full-page navigation.
  • External links are ignored.
  • Ctrl+click / middle-click (open in new tab) is preserved.
  • On AJAX error, the page gracefully falls back to full navigation via window.location.href.
  • Browser back/forward buttons are fully supported via popstate event listener.

Performance Considerations

  • No additional libraries or frameworks are required — pure vanilla JavaScript and CSS.
  • Server still renders the full page (Wicket's existing behavior), but only the content panel is extracted client-side, reducing perceived load time.
  • The loading bar provides immediate visual feedback, improving perceived performance even when server response times remain unchanged.
  • Total added DOM weight: ~3KB of JavaScript (minified) and ~500 bytes of CSS.

Acceptance Criteria

  •  Internal navigation no longer causes full-page reloads.
  •  Loading bar appears on every AJAX navigation and disappears on completion.
  •  Browser back/forward buttons navigate correctly via history API.
  •  Ctrl+click and middle-click still open links in new tabs.
  •  Excluded paths (downloads, raw files, blobs, diffs) use full-page navigation.
  •  Error handling falls back gracefully to full-page reload.
  •  No visual regressions on existing pages.
  •  No conflicts with Wicket's existing JavaScript event handlers.
  • Service Desk commented 10 hours ago

    This is not a viable "zero-refactor" change. OneDev's UI is Apache Wicket: each screen is a server-side page instance with its own component tree, Ajax callback URLs, header contributions (JS/CSS), and WebSocket observers. Replacing DOM from another page's HTML leaves the browser showing one screen while Wicket still owns another.

    Why the interceptor cannot work

    1. There is no global #content panel to swap. LayoutPage puts each page's body in <wicket:child> inside .main. wicket:id="content" is used on many inner panels, not as a layout-level main region.

    2. innerHTML swapping breaks Wicket. The new markup has different component ids and Ajax URLs. Scripts from the fetched page (OnDomReady, resource references, widget init) are not executed by innerHTML. Existing listeners (CodeMirror, Tippy, PerfectScrollbar, Wicket.Ajax) stay attached to detached nodes. Later clicks hit the original page's component tree or fail. That also fails the "no conflicts with Wicket's existing JavaScript event handlers" criterion.

    3. The chrome is not static. Sidebar menus, active items, and topbar title are rendered per page (SidebarMenuItem.isActive(), page title). Project, administration, and user pages have different menus. Keeping the old shell would show stale navigation.

    4. WebSockets are page-scoped. WebSocketService.observe(BasePage) registers change observables by Wicket page id. Live updates for issues, PRs, and builds would remain bound to the previous page.

    5. Server cost is not reduced. The proposal still requests a fully rendered page and extracts a fragment in the browser. Wicket still builds the whole page, including sidebar.

    6. AbstractAjaxBehavior cannot render an arbitrary other page's panel on the current request cycle. Doing that would require one mega-page that hosts every screen — a large rewrite, not a small interceptor.

    X-Requested-With: XMLHttpRequest is also not how Wicket Ajax works here. Wicket Ajax uses the Wicket-Ajax header (see existing callers such as blob folder view and markdown). A plain fetch() of a bookmarkable URL is a new page request, not a partial update of the current page.

    What OneDev already does

    Intra-page navigation already uses the History API without a full reload:

    • BasePage.pushState / onPopState and onedev.server.history in base.js
    • Many screens (blob browser, issue/PR/build lists, boards, and others) update via Wicket Ajax plus pushState
    • #ajax-loading-indicator already shows feedback during Ajax requests

    Bookmarkable links between different page classes correctly do a full navigation so Wicket can construct a new page, load that page's resources, and attach WebSockets.

    Recommendation

    Do not add a global fetch-and-swap navigation interceptor.

    If the flash on full page loads is the concern, Wicket-compatible options are:

    • Keep expanding Ajax + pushState within a page (the existing pattern)
    • Cosmetic only: keep the chrome painted during a normal load, or show a top progress bar on full navigations, without swapping another page's DOM

    A true SPA across all screens would mean replacing Wicket, which is a rewrite rather than a drop-in interceptor.

  • Zak Siddiqui commented 10 hours ago

    Evaluated as not feasible.

  • Zak Siddiqui changed state to 'Closed' 10 hours ago
    Previous Value Current Value
    Open
    Closed
  • Zak Siddiqui commented 10 hours ago

    Discussion: What if we implement this using Cross-Document View Transitions?

  • Zak Siddiqui changed state to 'Open' 10 hours ago
    Previous Value Current Value
    Closed
    Open
  • Zak Siddiqui commented 9 hours ago
  • Service Desk commented 9 hours ago

    @zaakiy Cross-document view transitions are a different mechanism from the interceptor, and they are compatible with Wicket in a way fetch-and-swap is not. They still do not meet the original SPA acceptance criteria, and I would not use them to implement that design.

    Why they do not break Wicket

    The browser snapshots the outgoing document, performs a normal same-origin navigation, snapshots the incoming document, then animates between those snapshots (@view-transition { navigation: auto; } on both pages).

    That full navigation is what OneDev needs: a new Wicket page instance, new Ajax callback URLs, header contributions, and WebSocket observers via WebSocketService.observe(BasePage). Nothing is innerHTML-swapped into the old page. Unsupported browsers ignore the at-rule and keep today's behavior.

    Why they are not the original proposal

    The original criteria were: no full-page reloads, extract/swap #content, keep the shell, History API as the router, and AbstractAjaxBehavior for other pages.

    View transitions still do a full reload. The server still renders the whole page, including sidebar. Chrome is not kept as a live Wicket tree; it is only painted over during the swap. Intra-page Ajax + onedev.server.history.pushState (blob browser, issue/PR/build lists, boards, and others) is same-document, so the cross-document at-rule never runs there.

    Practical limits in this UI

    1. Browser coverage. Chromium 126+ and Safari 18.2+. Firefox still does not ship cross-document transitions (same-document work is separate). Progressive enhancement is fine; it is not a uniform SPA feel.

    2. Only real document navigations. Sidebar BookmarkablePageLink / ViewStateAwarePageLink clicks and back/forward between page classes can animate. Wicket Ajax, window.location fallbacks, POSTs, and many redirects typically will not (navigation: auto is conservative).

    3. Default effect is a whole-document crossfade, which can still read as a flash. Pinning chrome with view-transition-name on .sidebar / .topbar is possible because those classes are stable in LayoutPage, but:

      • Sidebar content changes across project, administration, user, and my pages (SidebarMenuItem.isActive(), different menus). Morphing mismatched menus looks wrong.
      • List pages nest another .main inside LayoutPage's .main.autofit. A naive .main { view-transition-name: ... } would duplicate names and skip the transition.
      • SimplePage (login and similar) has no sidebar/topbar.
    4. Incoming snapshot vs Wicket init. OnDomReady work (CodeMirror, PerfectScrollbar, Tippy, and similar) often runs after first paint. The new-page snapshot can be of uninitialized widgets. Heavy pages (large diffs/lists) can also miss Chrome's ~4s "renderable" timeout and skip silently.

    5. Overlays and existing motion. Open dropdowns, floating panels, modals, and #ajax-loading-indicator can be captured in the old snapshot. layout.css already transitions .sidebar with transition: all 0.3s ease, which can fight view transitions. prefers-reduced-motion would need to turn this off.

    Recommendation

    Do not implement the original SPA interceptor via cross-document view transitions.

    If the remaining concern is only the flash on full loads, Wicket-safe options remain:

    • Keep expanding Ajax + pushState within a page (already the pattern)
    • A top progress bar on full document navigations (works in all browsers, no snapshot QA)
    • Optionally a later, separate experiment: opt-in @view-transition as progressive enhancement, with named chrome and reduced-motion — not as a substitute for SPA navigation

    Does this answer the view-transition question, or do you want that cosmetic experiment tracked separately?

  • Zak Siddiqui commented 9 hours ago

    Default effect is a whole-document crossfade

    Rather than implement it as a whole-of-document crossfades, what we can do is tie together the ID of elements that can confidently be considered to be the same between different pages.

    This would require adding IDs to divs like ones that have the following classes:

    1. side-info
    2. topbar
    3. sidebar

    @servicedesk what do you think about this?

  • Service Desk commented 9 hours ago

    @zaakiy Named pairing is how you avoid a whole-document crossfade, but matching is done with CSS view-transition-name, not HTML ids. I would not add ids on .side-info / .topbar / .sidebar for this.

    How matching actually works

    Cross-document view transitions pair elements that share the same view-transition-name on both documents. An HTML id only participates if you also set view-transition-name: auto (Level 2). match-element is same-document only and does not match across pages.

    .sidebar and .topbar already exist once on LayoutPage. Explicit names are enough:

    .sidebar { view-transition-name: sidebar; }
    .topbar { view-transition-name: topbar; }
    

    No markup ids are required. Wicket ids would also be a poor key: setOutputMarkupId(true) generates per-page ids (id1, id2, …) that do not match across navigations. SideInfoPanel already does that.

    .side-info is not layout chrome

    That class is not a stable shell region:

    1. Two different widgets share it. a.side-info is the “more info” trigger; div.side-info is the SideInfoPanel slide-out. Naming the class would try to pair a button with a panel.
    2. Duplicates on one page. Issue, PR, and build detail pages render both moreInfo and moreInfoDock with class="side-info". Duplicate view-transition-name values skip the entire transition.
    3. Most screens do not have it (lists, blob, admin, login). A named element with no counterpart just enters/exits; it does not pin chrome.
    4. Panel content is page-specific (issue vs PR vs build). Morphing those panels together would look wrong even when both sides exist.

    Named .sidebar / .topbar still morph the wrong content

    Pinning those boxes is possible, but the boxes are shared, not the contents:

    • Sidebar menus change across project, administration, user, and my pages (SidebarMenuItem.isActive(), different item lists). The snapshot morphs one menu into another.
    • Topbar title changes on every screen.
    • .sidebar already has transition: all 0.3s ease in layout.css, which fights view-transition snapshots.
    • SimplePage (login and similar) has neither sidebar nor topbar, so those names vanish and the default root crossfade remains.
    • Naming .main is unsafe: list and detail pages nest another .main inside LayoutPage’s .main. Duplicate names skip the transition.

    The visual result is still a full document navigation with a nicer paint of the chrome. Wicket still builds a new page, new Ajax URLs, and new WebSocket observers. Firefox still has no cross-document view transitions; Chromium 126+ and Safari 18.2+ only.

    Recommendation

    Do not add ids (or names) on .side-info, and do not treat named chrome as the original SPA interceptor.

    If the remaining goal is only “less flash on full loads”, the Wicket-safe options are unchanged:

    • Keep expanding Ajax + pushState within a page
    • A top progress bar on full document navigations (all browsers)
    • Optionally a later, separate experiment: view-transition-name on .sidebar and .topbar only, plus prefers-reduced-motion — not .side-info, and not as SPA navigation

    Does that answer the named-element question, or do you want that narrower chrome experiment tracked separately?

  • Zak Siddiqui commented 9 hours ago

    I'm happy with using the CSS classes instead of the ID-based named elements. In fact, this is the approach I prefer. I just didn't know that it could be done without IDs.


    Optionally a later, separate experiment: view-transition-name on .sidebar and .topbar only, plus prefers-reduced-motion — not .side-info, and not as SPA navigation

    I really like this idea, but it should not be optional. We should try to do it right now.

  • Service Desk commented 9 hours ago

    @zaakiy CSS view-transition-name on .sidebar and .topbar (not HTML ids, and not .side-info), plus prefers-reduced-motion, is the right scope. It is still a full document navigation with a nicer chrome paint, not the original SPA interceptor.

    @robin Zaakiy wants that chrome experiment implemented now rather than as a later optional follow-up. Can you take the implementation?

  • Zak Siddiqui commented 8 hours ago

    @servicedesk please redraft the requirements into a new issue based on the above discussion to ensure that it is not polluted with implementation ideas that are not feasible

  • Service Desk commented 8 hours ago

    @zaakiy Opened #3008 with the feasible chrome work only: cross-document view transitions, CSS view-transition-name on .sidebar and .topbar, and prefers-reduced-motion. The original SPA interceptor is not in that issue.

    Please comment on #3008 if the wording needs a change. Closing this evaluation as answered.

  • Service Desk changed state to 'Closed' 8 hours ago
    Previous Value Current Value
    Open
    Closed
1/1
Type
Question
Priority
Normal
Assignee
Labels
No labels
Issue Votes (0)
Watchers (3)
Reference
OD-3006
Please wait...
Connection lost or session expired, reload to recover
Page is in error, reload to recover