╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │183 // run gets its own. │ │184 pub const Session = struct { │ │185 data: Data = .{}, │ │186 + // session-lifetime allocations: login/user_id, persisted prefs, queued │ │187 + // actions. lives as long as the connection. │ │188 arena: *std.heap.ArenaAllocator, │ │189 + // current page's allocations: page data and the page-scoped strings widgets │ │190 + // build (form actions, links). owned by Nav, swapped on each navigation, so │ │191 + // it doesn't accumulate over a long-lived session. on the web/wasm paths, │ │192 + // where a render is one-shot, this points at the same arena as `arena`. │ │193 + page_arena: *std.heap.ArenaAllocator, │ │194 haxy_moment: ?evt.AdminDB.HashMap(.read_only) = null, // db cursor (null on the wasm side) │ │195 pending: std.ArrayList(Action) = .empty, // actions queued by widgets this frame │ │196 nav_back: bool = false, // set by input (escape) to request the native TUI pop a page; see Nav│ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │224 var session = Self{ │ │225 .data = data, │ │226 .arena = arena, │ │227 + // until a host swaps in a page-scoped arena (Nav does this), page │ │228 + // allocations land in the session arena. on the web path that's the│ │229 + // intended behavior, since the whole arena is per-request. │ │230 + .page_arena = arena, │ │231 .haxy_moment = try evt.currentMoment(evt.admin_repo_opts, repo), │ │232 }; │ │233 try session.loadUserPrefs(); │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────╮ │281 } │ │282 } │ │283 │ │284 + // record the requested page. `route` may borrow a name slice from the │ │285 + // current page's widget tree, which stays alive until Nav.sync runs (this │ │286 + // frame); sync re-anchors the name into the new page's arena. │ │287 fn navigate(self: *Session, route: RoutablePage) !void { │ │245 - const aa = self.arena.allocator(); │ │246 - self.data.current_page = switch (route) { │ │247 - .user => |name| .{ .user = try aa.dupe(u8, name) }, │ │248 - .user_settings => |name| .{ .user_settings = try aa.dupe(u8, name) },│ │249 - .user_auth => |name| .{ .user_auth = try aa.dupe(u8, name) }, │ │250 - else => route, │ │251 - }; │ │288 + self.data.current_page = route; │ │289 } │ │290 }; │ │291 │ ╰──────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────╮ │255 -pub fn run(io: std.Io, allocator: std.mem.Allocator, page: *const Page, session: *Session) !void {│ │256 - var nav = try Nav.init(allocator, page, session); │ │292 +pub fn run(io: std.Io, allocator: std.mem.Allocator, session: *Session) !void { │ │293 + var nav = try Nav.init(allocator, session); │ │294 defer nav.deinit(allocator); │ │295 │ │296 var terminal = try term.Terminal.init(io, allocator); │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮ │403 pub const Nav = struct { │ │404 root: Widget, │ │405 route: RoutablePage, │ │406 + // backs the current page (its Page data and the page-scoped strings its │ │407 + // widgets build). owned here, swapped on each navigation. session.page_arena │ │408 + // tracks whichever of these is current so widgets allocate into it. │ │409 + arena: *std.heap.ArenaAllocator, │ │410 history: std.ArrayList(Entry), │ │411 │ │371 - const Entry = struct { root: Widget, route: RoutablePage }; │ │412 + // each retained page keeps its own arena, freed when the page leaves the │ │413 + // stack (popped on back, evicted at cap, or on deinit). this is what keeps a │ │414 + // long-lived session from accumulating every page it ever visited. │ │415 + const Entry = struct { root: Widget, route: RoutablePage, arena: *std.heap.ArenaAllocator };│ │416 │ │373 - // cap on retained back-history so chain can't grow memory without bound │ │417 + // cap on retained back-history so the chain can't grow memory without bound │ │418 const max_history: usize = 16; │ │419 │ │376 - pub fn init(allocator: std.mem.Allocator, page: *const Page, session: *Session) !Nav { │ │420 + pub fn init(allocator: std.mem.Allocator, session: *Session) !Nav { │ │421 + const moment = session.haxy_moment orelse unreachable; │ │422 + const arena = try allocator.create(std.heap.ArenaAllocator); │ │423 + arena.* = std.heap.ArenaAllocator.init(allocator); │ │424 + errdefer { │ │425 + arena.deinit(); │ │426 + allocator.destroy(arena); │ │427 + } │ │428 + │ │429 + session.page_arena = arena; │ │430 + const route = session.data.current_page; │ │431 + │ │432 + const page = try arena.allocator().create(Page); │ │433 + page.* = try Page.init(arena, moment, route); │ │434 return .{ │ │435 .root = try initRoot(allocator, page, session), │ │379 - .route = session.data.current_page, │ │436 + .route = route, │ │437 + .arena = arena, │ │438 .history = .empty, │ │439 }; │ │440 } │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────╮ │441 │ │442 pub fn deinit(self: *Nav, allocator: std.mem.Allocator) void { │ │443 self.root.deinit(allocator); │ │386 - for (self.history.items) |*entry| entry.root.deinit(allocator);│ │444 + freeArena(allocator, self.arena); │ │445 + for (self.history.items) |*entry| { │ │446 + entry.root.deinit(allocator); │ │447 + freeArena(allocator, entry.arena); │ │448 + } │ │449 self.history.deinit(allocator); │ │450 } │ │451 │ ╰────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────╮ │452 + fn freeArena(allocator: std.mem.Allocator, arena: *std.heap.ArenaAllocator) void {│ │453 + arena.deinit(); │ │454 + allocator.destroy(arena); │ │455 + } │ │456 + │ │457 // reconcile the displayed root with the session's navigation state. returns │ │458 // false when escape was pressed with no history left, so the caller can quit. │ │459 // a forward nav (current_page moved to a different parent page) pushes the │ ╰───────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────╮ │393 - // current root and builds the new page; a back request pops and restores the │ │394 - // previous root. the new Page is allocated in the session arena so the widget │ │395 - // tree's data pointers stay valid for the life of the session. │ │460 + // current root and builds the new page in a fresh arena; a back request frees │ │461 + // the current page and restores the previous one. │ │462 pub fn sync(self: *Nav, allocator: std.mem.Allocator, session: *Session) !bool {│ │463 if (session.nav_back) { │ │464 session.nav_back = false; │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────╮ │465 const entry = self.history.pop() orelse return false;│ │466 self.root.deinit(allocator); │ │467 + freeArena(allocator, self.arena); │ │468 self.root = entry.root; │ │469 self.route = entry.route; │ │470 + self.arena = entry.arena; │ │471 + session.page_arena = entry.arena; │ │472 session.data.current_page = entry.route; │ │473 return true; │ │474 } │ ╰──────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │475 │ │476 if (session.data.current_page.parent() != self.route.parent()) { │ │477 const moment = session.haxy_moment orelse return true; │ │409 - const page = try session.arena.allocator().create(Page); │ │410 - page.* = try Page.init(session.arena, moment, session.data.current_page); │ │478 + const arena = try allocator.create(std.heap.ArenaAllocator); │ │479 + arena.* = std.heap.ArenaAllocator.init(allocator); │ │480 + errdefer freeArena(allocator, arena); │ │481 + │ │482 + session.page_arena = arena; │ │483 + const route = session.data.current_page; │ │484 + │ │485 + const page = try arena.allocator().create(Page); │ │486 + page.* = try Page.init(arena, moment, route); │ │487 const new_root = try initRoot(allocator, page, session); │ │412 - try self.history.append(allocator, .{ .root = self.root, .route = self.route }); │ │413 - // drop the oldest entry (freeing its widget tree) once we're over cap │ │488 + │ │489 + try self.history.append(allocator, .{ .root = self.root, .route = self.route, .arena = self.arena });│ │490 + // drop the oldest entry (freeing its widget tree and arena) once over cap │ │491 if (self.history.items.len > max_history) { │ │492 var oldest = self.history.orderedRemove(0); │ │493 oldest.root.deinit(allocator); │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯