fix(app): resolve session lineage across in-place session changes

This commit is contained in:
LukeParkerDev 2026-07-02 15:48:10 +10:00
commit 0eb31eb8e3
3 changed files with 236 additions and 38 deletions

View file

@ -0,0 +1,168 @@
import { expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createSessionLineage } from "@/pages/session/session-lineage"
type Lineage = { session: { id: string; directory: string } }
// Fake sync lineage store: peek reads a reactive cache, resolve returns a
// deferred promise the test settles or fails explicitly. The lineage memo is
// live (read below), so it recomputes eagerly on cache/status writes — throws
// surface at the write site, which is also where the enclosing ErrorBoundary
// would see them in the app. Assertions wrap write + read to cover both.
function createFixture(initial: Record<string, Lineage> = {}) {
const [cache, setCache] = createSignal(initial)
const deferred = new Map<string, PromiseWithResolvers<unknown>>()
const resolves: string[] = []
return {
resolves,
lineage: {
peek: (id: string) => cache()[id],
resolve: (id: string) => {
resolves.push(id)
const entry = deferred.get(id) ?? Promise.withResolvers<unknown>()
deferred.set(id, entry)
return entry.promise
},
},
settle(id: string) {
setCache({ ...cache(), [id]: { session: { id, directory: `/dir/${id}` } } })
deferred.get(id)?.resolve(undefined)
},
fail(id: string, error: unknown) {
deferred.get(id)?.reject(error)
},
remove(id: string) {
const next = { ...cache() }
delete next[id]
setCache(next)
},
}
}
const flush = async () => {
await Promise.resolve()
await Promise.resolve()
}
const lineageOf = (id: string): Lineage => ({ session: { id, directory: `/dir/${id}` } })
test("resolves an uncached session and exposes its lineage", async () => {
await createRoot(async (dispose) => {
const fixture = createFixture()
const current = createSessionLineage(
() => "ses_a",
() => fixture.lineage,
)
expect(current()).toBeUndefined()
await flush()
expect(fixture.resolves).toEqual(["ses_a"])
fixture.settle("ses_a")
await flush()
expect(current()?.session.id).toBe("ses_a")
dispose()
})
})
// Session tabs on the same server share one route instance, so navigating to
// another session changes the id in place; resolution must follow it instead
// of reporting the new session as missing.
test("re-resolves when navigating to an uncached session without a remount", async () => {
await createRoot(async (dispose) => {
const fixture = createFixture({ ses_a: lineageOf("ses_a") })
const [id, setId] = createSignal("ses_a")
const current = createSessionLineage(id, () => fixture.lineage)
await flush()
expect(current()?.session.id).toBe("ses_a")
expect(() => {
setId("ses_b")
current()
}).not.toThrow()
expect(fixture.resolves).toEqual(["ses_b"])
fixture.settle("ses_b")
await flush()
expect(current()?.session.id).toBe("ses_b")
dispose()
})
})
// A late failure from a session the user already navigated away from must not
// poison the currently viewed session.
test("ignores a stale resolution failure after the target changes", async () => {
await createRoot(async (dispose) => {
const fixture = createFixture()
const [id, setId] = createSignal("ses_a")
const current = createSessionLineage(id, () => fixture.lineage)
await flush()
setId("ses_b")
fixture.fail("ses_a", new Error("Session not found: ses_a"))
await flush()
expect(() => current()).not.toThrow()
fixture.settle("ses_b")
await flush()
expect(current()?.session.id).toBe("ses_b")
dispose()
})
})
test("returning to a pruned session re-resolves instead of throwing not found", async () => {
await createRoot(async (dispose) => {
const fixture = createFixture()
const [id, setId] = createSignal("ses_a")
const current = createSessionLineage(id, () => fixture.lineage)
await flush()
fixture.settle("ses_a")
await flush()
setId("ses_b")
fixture.settle("ses_b")
await flush()
fixture.remove("ses_a")
expect(() => {
setId("ses_a")
current()
}).not.toThrow()
expect(fixture.resolves).toEqual(["ses_a", "ses_b", "ses_a"])
fixture.settle("ses_a")
await flush()
expect(current()?.session.id).toBe("ses_a")
dispose()
})
})
// The viewed session is pinned in the cache, so disappearing after settlement
// means it was deleted; the boundary must show the not found fallback.
test("throws not found when the settled session is deleted", async () => {
await createRoot(async (dispose) => {
const fixture = createFixture()
const current = createSessionLineage(
() => "ses_a",
() => fixture.lineage,
)
await flush()
fixture.settle("ses_a")
await flush()
expect(current()?.session.id).toBe("ses_a")
expect(() => {
fixture.remove("ses_a")
current()
}).toThrow("Session not found: ses_a")
dispose()
})
})