Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/Ahondev/portfolio-v2/llms.txt

Use this file to discover all available pages before exploring further.

The SPA Bridge is the contract between WordPress and the React frontend. Every page request is served as a full HTML document containing an inline JSON payload — the view name, page data, and SEO metadata — which the React application reads on boot and on each subsequent client-side navigation. Understanding this bridge is essential for building new views or debugging data flow issues.

The Data Contract: window.__wp_data__

When WebController::view() handles a request, it writes a JSON object into the HTML response as an inline <script> block. The React SPA reads this on startup. The three globals written by client.php are:
// Set by PHP in client.php before any JS executes
window.__wp_data__  // page-specific data from the PHP controller
window.__wp_view__  // e.g. "home", "services_single"
window.__wp_seo__   // SEO fields: site_title, page_title, description, url, keywords, author
The __wp_view__ string maps directly to a key in routes.ts, telling the SPA which React component to render. The __wp_data__ object contains whatever data the PHP controller passed to $this->view('view_name', $data).
window.__wp_data__ and window.__wp_view__ are declared in the Window interface in wp-sync.ts. window.__wp_seo__ is set directly at runtime by wp-router.tsx after each navigation and by client.php on initial load.

Boot Sequence

When the browser loads the page, the SPA initialises through main.tsx:
1

Globals are available

PHP has already written window.__wp_view__, window.__wp_data__, and window.__wp_seo__ into the HTML before the JavaScript executes.
2

boot() is called

boot() from wp-router.tsx runs on page load. It reads the three globals and caches the current page’s data in an in-memory Map<string, PageData> keyed by the normalised URL path.
3

View is resolved

resolveView() reads window.__wp_view__, looks up the matching lazy-import function in routes.ts, and dynamically imports the React page component.
4

React mounts

createRoot(document.getElementById('root')) is called once. The resolved view component is rendered inside the <App> wrapper. Subsequent navigations reuse the same root instance.

TypeScript Helpers (wp-sync.ts)

The wp-sync.ts module exposes the public API for reading WordPress data inside React components:
wp-sync.ts
import { routes } from "@/routes"

// Read the current view name
export function getWPView(): string {
  return window.__wp_view__ ?? "not-found"
}

// Read page data (untyped)
export function getWPData<T = Record<string, any>>(): T {
  return (window.__wp_data__ ?? {}) as T
}

// React hook — use this inside components
export function useWPData<T = Record<string, any>>(): T {
  return getWPData<T>()
}

// Dynamically import the correct page component
export async function resolveView() {
  const view = getWPView()
  const loader = routes[view]
  if (!loader) {
    const mod = await import("@/pages/NotFound")
    return mod.default
  }
  const mod = await loader()
  return mod.default
}

// Programmatically update the window state (for testing/SSR)
export function setWPPage(payload: { view: string; data: Record<string, any> }) {
  window.__wp_view__ = payload.view
  window.__wp_data__ = payload.data ?? {}
}

Using useWPData in a Component

pages/BlogPost.tsx
import { useWPData } from "@/lib/wp-sync"

type BlogPostData = {
  post: {
    title: string
    content: string
    date: string
    excerpt: string
    readTime: string
    slug: string
    category: { name: string }
  }
  posts: Array<{ id: number; title: string; slug: string; date: string; readTime: string; excerpt: string; category?: { name: string } }>
}

export default function BlogPost() {
  const data = useWPData<BlogPostData>()
  const post = data.post

  if (!post) return <div>Article introuvable</div>

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.category.name} · {post.readTime}</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

SPA Navigation

After the initial boot, all internal link clicks are intercepted and handled client-side without a full page reload.

Click Interception

wp-router.tsx
document.addEventListener("click", (e) => {
  const link = (e.target as HTMLElement).closest("a") as HTMLAnchorElement
  if (!link) return
  if (!isInternalLink(link)) return  // skip external, _blank, download links
  e.preventDefault()
  navigate(link.href, false, false)
})
Links are considered external if:
  • target="_blank"
  • Origin differs from location.origin
  • Has download attribute
  • Has rel="external"

The navigate() Function

wp-router.tsx
export async function navigate(rawUrl: string, force = false, replace = false) {
  const url = normalize(rawUrl)
  if (url === normalize(location.href) && !force) return  // no-op on same URL
  const data = await fetchPage(url)
  await render(data, url, true, replace)
}
fetchPage() sends a GET {url}?json=1 request with an X-WP-SPA: 1 header. WordPress responds with pure JSON (no HTML shell):
{
  "view": "blog_single",
  "seo": {
    "site_title": "My Site",
    "page_title": "Understanding Headless WordPress",
    "description": "A deep dive into...",
    "url": "/blog/understanding-headless-wordpress"
  },
  "data": {
    "post": { "title": "...", "content": "...", "date": "...", "slug": "..." },
    "posts": []
  }
}
The response is cached in memory. Subsequent visits to the same URL are served instantly from the cache without a network request.

Prefetching

On mouseover of any internal link, prefetch() is called to warm the cache before the click:
export function prefetch(rawUrl: string) {
  const url = normalize(rawUrl)
  if (cache.has(url)) return
  fetchPage(url)  // fires and forgets
}

Browser History

  • Forward navigation: history.pushState({}, "", url)
  • Replace (no history entry): history.replaceState({}, "", url) — used when replace = true (e.g. on popstate)
  • Back/forward: window.addEventListener("popstate", ...) re-runs navigate() with force = true and replace = true

URL Normalisation

All URLs are normalised before cache lookups:
  • Trailing slashes are stripped: /blog//blog
  • Empty path becomes /: root URL is always /
function normalize(url: string): string {
  const u = new URL(url, location.origin)
  let path = u.pathname.replace(/\/$/, "")
  if (path === "") path = "/"
  return path
}
The SPA bridge does not use react-router-dom. Routing is entirely handled by wp-router.tsx through the History API, with React only responsible for rendering the correct component tree.

Build docs developers (and LLMs) love