diff --git a/CHANGELOG.md b/CHANGELOG.md index a97737b..e88a681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ This project follows [Semantic Versioning](https://semver.org/). --- +## [3.2.1] — 2026-06-05 + +### Fixed + +- **Router — `Route`/`Outlet` outlet wedged after leaving a nested route** — navigating from a nested child route (e.g. `/ui/button`) to a top-level route could permanently freeze the `Route` outlet on the old layout, so every later navigation changed the URL but not the rendered page. The async `Route.update` state machine relied on an `isUpdating`/`pendingUpdate` pair plus a `route.path === currentPath` insert guard; under a startup/navigation timing race this could leave `isUpdating` stuck `true` (every later update short-circuited) and could insert stale route content. `Route` and `Outlet` now use a monotonic per-update sequence — a load that is superseded mid-flight is discarded, the latest navigation always commits ("latest wins"), and rapid bursts of navigations can no longer wedge the outlet. +- **Router — `Outlet` kept stale child content and leaked on navigation** — when the active route no longer matched a nested child, the `Outlet` returned early without removing its previous child; it also removed the old child without disposing it, leaking that subtree's reactive bindings and listeners on every nested navigation. The `Outlet` now clears (and disposes) stale content when leaving the nested area and skips redundant re-renders of the same child. + +--- + ## [3.2.0] — 2026-06-01 A broad security-hardening and bug-fix release. No breaking changes. diff --git a/package.json b/package.json index 6806873..317189c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sibujs", - "version": "3.2.0", + "version": "3.2.1", "description": "A lightweight, function-based frontend framework that combines the best of React, Svelte, and Vue — with zero VDOM and maximum simplicity. Designed for developers who want fine-grained reactivity and full control without compilation or magic.", "keywords": [ "frontend", diff --git a/src/plugins/router.ts b/src/plugins/router.ts index a1e5741..9db170b 100644 --- a/src/plugins/router.ts +++ b/src/plugins/router.ts @@ -1347,8 +1347,14 @@ export function Route(): Node { let currentNode: Node | null = null; let loadingNode: Node | null = null; let errorNode: Node | null = null; - let isUpdating = false; - let currentPath = ""; + // Monotonic navigation sequence. Every update() invocation claims the next + // number; after an `await`, a resolution only commits if it is still the + // latest. This replaces the old `isUpdating` / `pendingUpdate` flags, which + // could (a) wedge `isUpdating === true` forever if a load was superseded + // mid-flight and (b) insert STALE route content because the + // `route.path === currentPath` guard compared shared mutable state. With a + // per-invocation token, superseded loads are simply dropped — "latest wins". + let navSeq = 0; let currentTopRoute: RouteDef | null = null; const cleanupNodes = () => { @@ -1455,22 +1461,18 @@ export function Route(): Node { anchor.parentNode.insertBefore(errorNode, anchor.nextSibling); }; - let pendingUpdate = false; - const update = async () => { if (!globalRouter) return; - if (isUpdating) { - pendingUpdate = true; - return; - } + // Claim the latest navigation slot. Any update still in flight for an + // earlier slot becomes stale and must not mutate the DOM when it resolves. + const seq = ++navSeq; const route = globalRouter.currentRoute; try { const match = globalRouter["matcher"].match(route.path); if (!match) { - currentPath = route.path; currentTopRoute = null; cleanupNodes(); return; @@ -1482,14 +1484,9 @@ export function Route(): Node { // Skip re-render if the top-level route is the same (child routes handled by Outlet) if (routeDef === currentTopRoute && currentNode) { - currentPath = route.path; return; } - isUpdating = true; - currentPath = route.path; - currentTopRoute = routeDef; - // Handle redirect routes (should be handled by router, but safety check) if ("redirect" in routeDef) { const redirectPath = typeof routeDef.redirect === "function" ? routeDef.redirect(route) : routeDef.redirect; @@ -1515,28 +1512,31 @@ export function Route(): Node { } const component = await globalRouter.loadComponent(routeDef, route.path); + + // A newer navigation superseded us while loading — drop this result. + // The newer update() owns the DOM and will (or already did) render. + if (seq !== navSeq) return; + const node = component(); - if (node && anchor.parentNode && route.path === currentPath) { + if (node && anchor.parentNode) { + // Commit only now that we know we are the latest resolution. + currentTopRoute = routeDef; cleanupNodes(); anchor.parentNode.insertBefore(node, anchor.nextSibling); currentNode = node; } } catch (error) { + if (seq !== navSeq) return; hideLoading(); console.error("[Route] Component error:", error); showError(error instanceof Error ? error : new Error(String(error)), routeDef); } } } catch (error) { + if (seq !== navSeq) return; console.error("[Route] Update failed:", error); showError(error instanceof Error ? error : new Error(String(error))); - } finally { - isUpdating = false; - if (pendingUpdate) { - pendingUpdate = false; - update(); - } } }; @@ -2079,30 +2079,62 @@ export function __removeRouterPagehideHandler(): void { export function Outlet(): Node { const anchor = document.createComment("route-outlet-nested"); let currentNode: Node | null = null; + let currentChild: RouteDef | null = null; + // Mirror Route()'s "latest wins" guard so a superseded child load cannot + // resurrect stale content after a newer navigation. + let navSeq = 0; + + const clearCurrent = () => { + if (currentNode) { + // Dispose first so the child's reactive bindings/listeners are released — + // a bare removeChild would leak them on every nested navigation. + dispose(currentNode); + if (currentNode.parentNode) currentNode.parentNode.removeChild(currentNode); + currentNode = null; + } + currentChild = null; + }; const update = async () => { if (!globalRouter) return; + const seq = ++navSeq; const route = globalRouter.currentRoute; - if (route.matched.length < 2) return; + + // Left the nested area (or matched a flat route): drop any stale child so + // the layout doesn't keep rendering the previous page's content. + if (route.matched.length < 2) { + clearCurrent(); + return; + } // Render the deepest matched route's component const childRoute = route.matched[route.matched.length - 1]; - if (!childRoute || !("component" in childRoute)) return; + if (!childRoute || !("component" in childRoute)) { + clearCurrent(); + return; + } + + // Same child already mounted — nothing to do. + if (childRoute === currentChild && currentNode) return; try { // Use a composite cache key so parent and child don't collide const cacheKey = `${route.path}\0${childRoute.path}`; const component = await globalRouter.loadComponent(childRoute, cacheKey); + + // A newer navigation superseded us while loading — discard. + if (seq !== navSeq) return; + const node = component(); if (node && anchor.parentNode) { - if (currentNode?.parentNode) { - currentNode.parentNode.removeChild(currentNode); - } + clearCurrent(); anchor.parentNode.insertBefore(node, anchor.nextSibling); currentNode = node; + currentChild = childRoute; } } catch (error) { + if (seq !== navSeq) return; console.error("[Outlet] Failed to render child route:", error); } }; diff --git a/tests/router.nested-to-top.test.ts b/tests/router.nested-to-top.test.ts new file mode 100644 index 0000000..70ed29e --- /dev/null +++ b/tests/router.nested-to-top.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { a as aTag, div, main as mainTag, span } from "../src/core/rendering/html"; +import { mount } from "../src/core/rendering/mount"; +import { createRouter, destroyRouter, lazy, navigate, Outlet, Route, route } from "../src/plugins/router"; + +const wait = (ms = 250) => new Promise((r) => setTimeout(r, ms)); + +describe("Navigating from a nested child route to a top-level route", () => { + let container: HTMLDivElement; + + beforeEach(() => { + window.history.replaceState({}, "", "/"); + container = document.createElement("div"); + container.id = "app"; + document.body.appendChild(container); + }); + + afterEach(() => { + try { + destroyRouter(); + } catch {} + document.body.removeChild(container); + }); + + it("swaps the outlet content (faithful to sibujs-web structure)", async () => { + // A nav with reactive active-link styling — mirrors Navbar/NavLink, which + // adds many reactive route() readers alongside the Route outlet. + function NavLink(href: string) { + return aTag( + { + href, + class: () => (route().path.startsWith(href) ? "active" : "inactive"), + on: { + click: (e: Event) => { + e.preventDefault(); + navigate(href); + }, + }, + }, + href, + ); + } + + // Nested layout with its own reactive route() readers (mirrors PathSidebar). + function UILayout(content: Node) { + return div("ui-layout", [ + div("ui-sidebar", [ + aTag({ href: "/ui/button", class: () => (route().path === "/ui/button" ? "on" : "off") }, "Button link"), + span(() => `current: ${route().path}`), + ]), + div("ui-content", content), + ]); + } + function UIWrapper() { + return UILayout(Outlet()); + } + + const delayed = (v: T, ms: number) => new Promise((r) => setTimeout(() => r(v), ms)); + const ButtonPage = lazy(() => delayed({ default: () => div("page", "Button page content") }, 40)); + const Features = lazy(() => delayed({ default: () => div("page", "Features page content") }, 40)); + + function App() { + createRouter( + [ + { path: "/features", component: Features }, + { + path: "/ui", + component: UIWrapper, + children: [{ path: "/button", component: ButtonPage }], + }, + ], + { mode: "history" }, + ); + return div("app", [div("nav", [NavLink("/features"), NavLink("/ui")]), mainTag({ class: "main" }, Route())]); + } + + window.history.replaceState({}, "", "/ui/button"); + mount(App, container); + await wait(350); + + const mainEl = container.querySelector(".main") as HTMLElement; + expect(mainEl.textContent).toContain("Button page content"); + expect(mainEl.querySelector(".ui-sidebar")).toBeTruthy(); + + // Leave the nested route for a top-level sibling. The Route outlet must + // swap the whole nested layout (sidebar + Outlet child) for the new page. + await navigate("/features"); + await wait(350); + + expect(mainEl.textContent).toContain("Features page content"); + expect(mainEl.textContent).not.toContain("Button page content"); + expect(mainEl.querySelector(".ui-sidebar")).toBeNull(); + }); + + it("survives a rapid burst of navigations without wedging (latest wins)", async () => { + function UILayout(content: Node) { + return div("ui-layout", [div("ui-sidebar", "sidebar"), div("ui-content", content)]); + } + function UIWrapper() { + return UILayout(Outlet()); + } + const delayed = (v: T, ms: number) => new Promise((r) => setTimeout(() => r(v), ms)); + const ButtonPage = lazy(() => delayed({ default: () => div("page", "Button page content") }, 30)); + const Features = lazy(() => delayed({ default: () => div("page", "Features page content") }, 30)); + const Learn = lazy(() => delayed({ default: () => div("page", "Learn page content") }, 30)); + + function App() { + createRouter( + [ + { path: "/features", component: Features }, + { path: "/learn", component: Learn }, + { path: "/ui", component: UIWrapper, children: [{ path: "/button", component: ButtonPage }] }, + ], + { mode: "history" }, + ); + return div("app", mainTag({ class: "main" }, Route())); + } + + window.history.replaceState({}, "", "/ui/button"); + mount(App, container); + await wait(200); + + const mainEl = container.querySelector(".main") as HTMLElement; + + // Fire several navigations before any of them settle. The last one wins. + navigate("/features"); + navigate("/ui/button"); + navigate("/learn"); + await wait(400); + + expect(mainEl.textContent).toContain("Learn page content"); + expect(mainEl.textContent).not.toContain("Features page content"); + expect(mainEl.querySelector(".ui-sidebar")).toBeNull(); + + // And the outlet is not wedged — a further navigation still renders. + await navigate("/features"); + await wait(300); + expect(mainEl.textContent).toContain("Features page content"); + }); +});