/** * `next/navigation` shim — environment-aware app-router hooks. * * Defaults to lightweight stubs backed by `window.location` and * `URLSearchParams` so lib components that read the URL work outside * a Next.js router shell. Next.js hosts can opt into the REAL * `next/navigation` exports (full client-router integration — * `router.replace(href, { scroll: false })`, etc.) by calling * {@link registerNavigation} ONCE at app init: * * // hub: lib/embed-shim-registration.ts * import { * useRouter, usePathname, useSearchParams, useParams, * redirect, permanentRedirect, notFound, * } from 'next/navigation' * import { registerNavigation } from '@flamingo-stack/openframe-frontend-core/embed-shims' * registerNavigation({ * useRouter, usePathname, useSearchParams, useParams, * redirect, permanentRedirect, notFound, * }) * * After registration, every exported hook in this file delegates to * the real implementation. Without registration, the stubs run: * * - `useRouter()` → push/replace do a same-origin SPA navigation via the * History API (`pushState`/`replaceState` + a synthetic `popstate`), so a * query-param write never reloads the document; cross-origin/malformed * hrefs fall back to `window.location`. back/forward use * `history.back/forward`; refresh reloads; prefetch is a no-op. * - `usePathname()` → returns `window.location.pathname` (popstate- * subscribed so back/forward re-renders). * - `useSearchParams()` → returns `URLSearchParams` view of * `window.location.search` (popstate-subscribed). * - `useParams()` → returns `{}` — embedders that need dynamic * params should parse them from `usePathname()` themselves. * - `notFound()` / `redirect()` / `permanentRedirect()` — * best-effort equivalents using `window.location`. * * The fallback path subscribes to `popstate`. Native `pushState`/ * `replaceState` do NOT fire popstate, but the fallback `useRouter`'s * push/replace dispatch a synthetic `popstate` so this file's own * `usePathname`/`useSearchParams` subscribers re-render. A URL mutated * through some OTHER API still won't auto-re-render here — embedders * that need that should register a real router or supply their own. */ 'use client'; import { useEffect, useState, type Context, type ReactNode } from 'react'; // --- Router shape — matches Next's AppRouterInstance surface enough to // --- satisfy hub callsites that pass options like `{ scroll: false }`. interface NavigateOptions { scroll?: boolean; } interface PrefetchOptions { kind?: unknown; } interface RouterStub { push: (href: string, options?: NavigateOptions) => void; replace: (href: string, options?: NavigateOptions) => void; back: () => void; forward: () => void; refresh: () => void; prefetch: (href: string, options?: PrefetchOptions) => void; } // --- Registration surface — partial so a host can register only the // --- hooks it needs (e.g. routes that don't use redirect()). export interface NavigationImpl { useRouter: () => RouterStub; usePathname: () => string; useSearchParams: () => URLSearchParams; useParams: >() => T; redirect: (url: string, type?: unknown) => never; permanentRedirect: (url: string, type?: unknown) => never; notFound: () => never; } let impl: Partial = {}; /** * Register real `next/navigation` exports. Merges with any prior * registration so partial-overrides are safe. Call ONCE at app init * in a Next.js host. */ export function registerNavigation(nav: Partial): void { impl = { ...impl, ...nav }; } // --- Fallback impls ---------------------------------------------------- const noopRouter: RouterStub = { push: () => {}, replace: () => {}, back: () => {}, forward: () => {}, refresh: () => {}, prefetch: () => {}, }; /** * SPA navigation for the unregistered fallback. Uses the History API instead of * `window.location.assign/replace` so a same-origin URL change (e.g. a table's * `?search=` write via `useApiParams`) never triggers a full document reload — * even if a real router was never registered, or the registration landed on a * DIFFERENT module instance than this one (duplicate lib copy / ESM-CJS dual * package). A synthetic `popstate` is dispatched so this file's own * `usePathname`/`useSearchParams` subscribers re-render in response. * Cross-origin or malformed hrefs still fall back to a real `window.location` * navigation, which is the only correct behavior there. */ function softNavigate(href: string, mode: 'push' | 'replace'): void { if (typeof window === 'undefined') return; try { const url = new URL(href, window.location.href); if (url.origin !== window.location.origin) { // Preserve the caller's history semantics: replace() must not leave a // back-button entry, so it uses `location.replace`, not `location.assign`. if (mode === 'push') window.location.assign(url.href); else window.location.replace(url.href); return; } if (mode === 'push') window.history.pushState(null, '', url.href); else window.history.replaceState(null, '', url.href); window.dispatchEvent(new PopStateEvent('popstate')); } catch { // Malformed href — a real navigation is safer than swallowing it, but still // honor push vs replace history semantics. if (mode === 'push') window.location.assign(href); else window.location.replace(href); } } // `useFallback*`, not `fallbackUse*`: two of these call hooks, and the // linter identifies a custom hook purely by the `use` PREFIX. Under the old // names `useLocationSubscription` looked like a hook called from a plain // function, which is the one shape the rules-of-hooks check cannot verify. // The whole family is renamed together so the set stays symmetric. function useFallbackRouter(): RouterStub { if (typeof window === 'undefined') return noopRouter; return { push: (href: string) => softNavigate(href, 'push'), replace: (href: string) => softNavigate(href, 'replace'), back: () => window.history.back(), forward: () => window.history.forward(), refresh: () => window.location.reload(), prefetch: () => {}, }; } function readLocation(read: () => T, fallback: T): T { return typeof window === 'undefined' ? fallback : read(); } /** Subscribes to `popstate` only. pushState/replaceState do NOT fire * popstate by design — callers that mutate the URL programmatically * (and want their component to re-render in response) need a real * router. */ function useLocationSubscription(): number { const [tick, setTick] = useState(0); useEffect(() => { if (typeof window === 'undefined') return undefined; const onChange = () => setTick(t => t + 1); window.addEventListener('popstate', onChange); return () => window.removeEventListener('popstate', onChange); }, []); return tick; } function useFallbackPathname(): string { useLocationSubscription(); return readLocation(() => window.location.pathname, '/'); } function useFallbackSearchParams(): URLSearchParams { useLocationSubscription(); return readLocation(() => new URLSearchParams(window.location.search), new URLSearchParams()); } function useFallbackParams>(): T { return {} as T; } function fallbackRedirect(url: string): never { if (typeof window !== 'undefined') window.location.assign(url); throw new Error(`[next-navigation shim] redirect(${url})`); } function fallbackPermanentRedirect(url: string): never { if (typeof window !== 'undefined') window.location.replace(url); throw new Error(`[next-navigation shim] permanentRedirect(${url})`); } function fallbackNotFound(): never { throw new Error('[next-navigation shim] notFound()'); } // --- Public surface — each export checks the registry and falls // --- through to the fallback. Hooks must remain hooks (no early- // --- return-before-hook) — the registry lookup is itself synchronous // --- and the registered impl is a hook in its own right. export function useRouter(): RouterStub { return (impl.useRouter ?? useFallbackRouter)(); } export function usePathname(): string { return (impl.usePathname ?? useFallbackPathname)(); } export function useSearchParams(): URLSearchParams { return (impl.useSearchParams ?? useFallbackSearchParams)(); } export function useParams>(): T { return ((impl.useParams as (() => T) | undefined) ?? useFallbackParams)(); } export function redirect(url: string, type?: unknown): never { return (impl.redirect ?? fallbackRedirect)(url, type); } export function permanentRedirect(url: string, type?: unknown): never { return (impl.permanentRedirect ?? fallbackPermanentRedirect)(url, type); } export function notFound(): never { return (impl.notFound ?? fallbackNotFound)(); } /** Match Next's RedirectType enum surface for code that imports it. */ export const RedirectType = { push: 'push' as const, replace: 'replace' as const, }; /** Match Next's ServerInsertedHTMLContext surface — non-functional in * the embed environment but importable so consumers don't crash. * * Typed as the context Next exports rather than `any`: the value stays * `null` (nothing to insert outside an SSR shell), and the declared type * documents what a Next host would put here. */ export const ServerInsertedHTMLContext: Context<((callback: () => ReactNode) => void) | null> | null = null;