Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
95db6ce
Updated CHANGELOG and package.json
hexplus Mar 28, 2026
56080d8
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 28, 2026
7eeec49
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 28, 2026
14a9cd4
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
9487727
ci: use npm install instead of npm ci
hexplus Mar 29, 2026
6b4bd83
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0b9a0cc
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0777184
trusted-publisher
hexplus Mar 29, 2026
4d46e82
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
bea9788
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
825a8dc
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
55c4436
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0d2c7e0
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
8da81e8
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
325ce5d
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Mar 29, 2026
0cad329
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 1, 2026
aea6787
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 4, 2026
00e5e88
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 7, 2026
b10a2c5
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 7, 2026
639eae0
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 9, 2026
405e4fe
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 11, 2026
ee7cf48
Updated main
hexplus Apr 11, 2026
8c77fca
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 11, 2026
da6d752
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 11, 2026
c047837
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 12, 2026
a52fffc
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 12, 2026
43b5675
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 14, 2026
44df880
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 18, 2026
aba311a
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 19, 2026
4bf3286
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Apr 19, 2026
a086428
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus May 29, 2026
e316ae0
Missing update package.json
hexplus May 29, 2026
278bfc6
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus May 29, 2026
08bc9b8
Merge branch 'main' of https://github.com/hexplus/SibuJS
hexplus Jun 1, 2026
e2d880b
fix(router): prevent Route/Outlet outlet from wedging on stale conten…
hexplus Jun 5, 2026
c8f789a
Fixed test
hexplus Jun 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
84 changes: 58 additions & 26 deletions src/plugins/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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();
}
}
};

Expand Down Expand Up @@ -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);
}
};
Expand Down
140 changes: 140 additions & 0 deletions tests/router.nested-to-top.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(v: T, ms: number) => new Promise<T>((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 = <T>(v: T, ms: number) => new Promise<T>((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");
});
});
Loading