Compare commits
6 commits
dev
...
session-ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cecdc09485 |
||
|
|
cd06ecb694 | ||
|
|
b2e76a10c7 | ||
|
|
0eb31eb8e3 | ||
|
|
a09f6a87c3 | ||
|
|
d9e62f4eee |
7 changed files with 501 additions and 58 deletions
145
packages/app/e2e/regression/terminal-tab-switch.spec.ts
Normal file
145
packages/app/e2e/regression/terminal-tab-switch.spec.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/TerminalTabSwitch"
|
||||||
|
const projectID = "proj_terminal_tab_switch"
|
||||||
|
const sessionA = "ses_terminal_tab_a"
|
||||||
|
const sessionB = "ses_terminal_tab_b"
|
||||||
|
const titleA = "Alpha session"
|
||||||
|
const titleB = "Beta session"
|
||||||
|
const ptyID = "pty_tab_switch"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
// Marks the terminal DOM node so a remount (fresh node) is detectable.
|
||||||
|
const PROBE = "original"
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
|
// Terminals are workspace-scoped: switching between session tabs in the same
|
||||||
|
// workspace must keep the terminal mounted and its PTY connection open instead
|
||||||
|
// of tearing it down and reconnecting.
|
||||||
|
test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
|
||||||
|
const connections = await setup(page)
|
||||||
|
|
||||||
|
await page.goto(sessionHref(sessionA))
|
||||||
|
await expectSessionTitle(page, titleA)
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
await expect.poll(() => connections.length).toBe(1)
|
||||||
|
await writeProbe(page)
|
||||||
|
|
||||||
|
await switchTab(page, titleB)
|
||||||
|
await expectSessionTitle(page, titleB)
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
expect(await readProbe(page)).toBe(PROBE)
|
||||||
|
expect(connections.length).toBe(1)
|
||||||
|
|
||||||
|
await switchTab(page, titleA)
|
||||||
|
await expectSessionTitle(page, titleA)
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
expect(await readProbe(page)).toBe(PROBE)
|
||||||
|
expect(connections.length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||||
|
|
||||||
|
async function switchTab(page: Page, title: string) {
|
||||||
|
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeProbe(page: Page) {
|
||||||
|
await page.locator('[data-component="terminal"]').evaluate((el, probe) => {
|
||||||
|
;(el as Probed).__e2eProbe = probe
|
||||||
|
}, PROBE)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readProbe(page: Page) {
|
||||||
|
return page.locator('[data-component="terminal"]').evaluate((el) => (el as Probed).__e2eProbe)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(page: Page) {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "terminal-tab-switch",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
},
|
||||||
|
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.route("**/pty", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route(`**/pty/${ptyID}`, (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||||
|
)
|
||||||
|
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const connections: string[] = []
|
||||||
|
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
|
||||||
|
connections.push(ws.url())
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, server, sessions }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, server, sessions: [sessionA, sessionB] },
|
||||||
|
)
|
||||||
|
return connections
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(id: string, title: string, created: number) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created, updated: created },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionHref(sessionID: string) {
|
||||||
|
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||||
|
}
|
||||||
|
|
@ -54,7 +54,7 @@ import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||||
|
|
||||||
import { SessionPage, TargetSessionRoute as TargetSessionRouteContent } from "@/pages/session"
|
import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { NewHome, LegacyHome } from "@/pages/home"
|
import { NewHome, LegacyHome } from "@/pages/home"
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
@ -100,6 +100,9 @@ const TargetSessionRoute = () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// Owns the server-identity remount. Session changes must NOT remount this
|
||||||
|
// subtree (SessionRouteErrorBoundary resets and createSessionLineage
|
||||||
|
// re-resolves reactively instead); both rely on this key for server changes.
|
||||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>
|
<ServerSyncProvider server={conn}>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
|
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
|
||||||
|
import { sessionNotFoundError } from "@/utils/server-errors"
|
||||||
import { rootSession } from "@/utils/session-route"
|
import { rootSession } from "@/utils/session-route"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||||
|
|
||||||
|
|
@ -235,7 +236,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
const active = generation(sessionID)
|
const active = generation(sessionID)
|
||||||
const request = client.session.get({ sessionID }).then((result) => {
|
const request = client.session.get({ sessionID }).then((result) => {
|
||||||
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
|
if (!result.data) throw sessionNotFoundError(sessionID)
|
||||||
if (generations.get(sessionID) !== active) return result.data
|
if (generations.get(sessionID) !== active) return result.data
|
||||||
return remember(result.data)
|
return remember(result.data)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
createMemo,
|
createMemo,
|
||||||
createEffect,
|
createEffect,
|
||||||
createComputed,
|
createComputed,
|
||||||
createSignal,
|
|
||||||
on,
|
on,
|
||||||
onMount,
|
onMount,
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
|
|
@ -85,10 +84,11 @@ import { Identifier } from "@/utils/id"
|
||||||
import { diffs as list } from "@/utils/diffs"
|
import { diffs as list } from "@/utils/diffs"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { formatServerError, isSessionNotFoundError } from "@/utils/server-errors"
|
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||||
import { createSessionOwnership } from "./session/session-ownership"
|
import { createSessionOwnership } from "./session/session-ownership"
|
||||||
|
import { createSessionLineage } from "./session/session-lineage"
|
||||||
|
|
||||||
type FollowupItem = FollowupDraft & { id: string }
|
type FollowupItem = FollowupDraft & { id: string }
|
||||||
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
||||||
|
|
@ -103,10 +103,6 @@ const sessionViewState = () => ({
|
||||||
changes: "git" as ChangeMode,
|
changes: "git" as ChangeMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
function isLocalSessionNotFoundError(error: unknown, sessionID: string) {
|
|
||||||
return error instanceof Error && error.message === `Session not found: ${sessionID}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
|
function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
|
||||||
if (!sessionID) return false
|
if (!sessionID) return false
|
||||||
return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID)
|
return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID)
|
||||||
|
|
@ -143,14 +139,16 @@ export function SessionPage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TargetSessionRoute() {
|
// Rendered under app.tsx's TargetSessionRoute, which owns the per-server keyed
|
||||||
|
// remount around the server-scoped providers. Nothing here may key on the
|
||||||
|
// session ID: session tabs on the same server share this route instance, and
|
||||||
|
// workspace-scoped state (terminal, directory providers) lives below.
|
||||||
|
export function TargetSessionRouteContent() {
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
return (
|
return (
|
||||||
<Show when={`${params.serverKey}\0${params.id}`} keyed>
|
|
||||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
||||||
<ResolvedTargetSessionRoute />
|
<ResolvedTargetSessionRoute />
|
||||||
</SessionRouteErrorBoundary>
|
</SessionRouteErrorBoundary>
|
||||||
</Show>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,8 +222,12 @@ function ResolvedTargetSessionRoute() {
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
const sync = useServerSync()
|
||||||
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
||||||
const current = createSessionLineage(() => params.id)
|
const current = createSessionLineage(
|
||||||
|
() => params.id,
|
||||||
|
() => sync().session.lineage,
|
||||||
|
)
|
||||||
const directory = createMemo(() => current()?.session.directory)
|
const directory = createMemo(() => current()?.session.directory)
|
||||||
const targetDirectory = () => directory()!
|
const targetDirectory = () => directory()!
|
||||||
|
|
||||||
|
|
@ -240,6 +242,10 @@ function ResolvedTargetSessionRoute() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||||
|
{/* Non-keyed: closes only while the target's directory is unknown (uncached
|
||||||
|
lineage mid-resolution), which tears down the workspace subtree including
|
||||||
|
the terminal. Same-workspace tab switches keep it open because warm
|
||||||
|
targets resolve synchronously from the sync cache. */}
|
||||||
<Show when={directory()}>
|
<Show when={directory()}>
|
||||||
<Show
|
<Show
|
||||||
when={settings.general.newLayoutDesigns()}
|
when={settings.general.newLayoutDesigns()}
|
||||||
|
|
@ -256,42 +262,9 @@ function ResolvedTargetSessionRoute() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reactive session lineage for the target session route, read from the sync store.
|
// Owns the workspace-identity remount. Must not include the session ID in the
|
||||||
// The route keys its consumer to the session ID, so resolution runs once per target.
|
// key: SessionPage handles session changes reactively, and remounting here
|
||||||
// Resolution is imperative rather than a resource on purpose: a resource created here
|
// destroys workspace-scoped state (terminal PTYs, file/prompt providers).
|
||||||
// would be created inside the router's navigation transition, and suspending that
|
|
||||||
// transition deadlocks the URL commit and double-mounts the session header portals
|
|
||||||
// from the transition's shadow render. `lineage.resolve` fills the sync store, which
|
|
||||||
// the returned accessor observes; resolve failures rethrow on read so the enclosing
|
|
||||||
// SessionRouteErrorBoundary renders the scoped session error.
|
|
||||||
function createSessionLineage(sessionID: () => string) {
|
|
||||||
const sync = useServerSync()
|
|
||||||
const cached = createMemo(() => sync().session.lineage.peek(sessionID()))
|
|
||||||
const [failure, setFailure] = createSignal<unknown>()
|
|
||||||
const [settled, setSettled] = createSignal(false)
|
|
||||||
onMount(() => {
|
|
||||||
if (cached()) {
|
|
||||||
setSettled(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sync()
|
|
||||||
.session.lineage.resolve(sessionID())
|
|
||||||
.then(() => setSettled(true))
|
|
||||||
.catch((error) => setFailure(() => error))
|
|
||||||
})
|
|
||||||
return createMemo(() => {
|
|
||||||
const error = failure()
|
|
||||||
if (error) throw error
|
|
||||||
const lineage = cached()
|
|
||||||
// The viewed session is pinned and pinned lineages are exempt from cache pruning,
|
|
||||||
// so a lineage missing after settlement means the session (or an ancestor) was
|
|
||||||
// deleted, possibly by another client. Match the resolve error so the boundary
|
|
||||||
// shows the session not found fallback.
|
|
||||||
if (!lineage && settled()) throw new Error(`Session not found: ${sessionID()}`)
|
|
||||||
return lineage
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function TargetSessionPage() {
|
function TargetSessionPage() {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
|
@ -413,6 +386,7 @@ export default function Page() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
|
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
|
||||||
|
const sessionPanelKey = createMemo(() => (params.id ? `${serverSDK().scope}\0${params.id}` : undefined))
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
|
|
@ -2044,13 +2018,19 @@ export default function Page() {
|
||||||
width: sessionPanelWidth(),
|
width: sessionPanelWidth(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SessionPanelFrame newLayout={settings.general.newLayoutDesigns()} raised={!!params.id}>
|
|
||||||
{settings.general.newLayoutDesigns() ? (
|
{settings.general.newLayoutDesigns() ? (
|
||||||
|
<Show when={sessionPanelKey()} keyed>
|
||||||
|
{(_) => (
|
||||||
|
<SessionPanelFrame newLayout raised={!!params.id}>
|
||||||
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
|
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
|
||||||
) : (
|
|
||||||
sessionPanelContent()
|
|
||||||
)}
|
|
||||||
</SessionPanelFrame>
|
</SessionPanelFrame>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
) : (
|
||||||
|
<SessionPanelFrame newLayout={false} raised={!!params.id}>
|
||||||
|
{sessionPanelContent()}
|
||||||
|
</SessionPanelFrame>
|
||||||
|
)}
|
||||||
|
|
||||||
<Show when={desktopReviewOpen()}>
|
<Show when={desktopReviewOpen()}>
|
||||||
<div onPointerDown={() => size.start()}>
|
<div onPointerDown={() => size.start()}>
|
||||||
|
|
|
||||||
69
packages/app/src/pages/session/session-lineage.ts
Normal file
69
packages/app/src/pages/session/session-lineage.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||||
|
import { sessionNotFoundError } from "@/utils/server-errors"
|
||||||
|
|
||||||
|
type LineageStore<T> = { peek: (id: string) => T | undefined; resolve: (id: string) => Promise<unknown> }
|
||||||
|
|
||||||
|
type Resolution<T> = { id: string; store: LineageStore<T> } & (
|
||||||
|
| { state: "pending" }
|
||||||
|
| { state: "settled" }
|
||||||
|
| { state: "failed"; failure: unknown }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reactive session lineage for the target session route, read from the sync store.
|
||||||
|
// All session tabs on a server share one route instance, so the target session ID
|
||||||
|
// changes in place; the effect is only a trigger that starts resolution for the
|
||||||
|
// current target, and each run cancels the previous one through onCleanup so a
|
||||||
|
// late result from an abandoned target is dropped. Resolution is imperative rather
|
||||||
|
// than a resource on purpose: a resource created here would be created inside the
|
||||||
|
// router's navigation transition, and suspending that transition deadlocks the URL
|
||||||
|
// commit and double-mounts the session header portals from the transition's shadow
|
||||||
|
// render.
|
||||||
|
//
|
||||||
|
// The returned accessor is a pure derivation. The sync cache is authoritative, and
|
||||||
|
// status only applies while it matches the current target (store + session ID): on
|
||||||
|
// navigation or store replacement the memo re-evaluates before the trigger runs,
|
||||||
|
// so trusting a previous target's settlement would fabricate a not-found for a
|
||||||
|
// session that simply has not resolved yet. Resolve failures rethrow on read so
|
||||||
|
// the enclosing SessionRouteErrorBoundary renders the scoped session error.
|
||||||
|
export function createSessionLineage<T>(sessionID: () => string, lineage: () => LineageStore<T>) {
|
||||||
|
const cached = createMemo(() => lineage().peek(sessionID()))
|
||||||
|
const [status, setStatus] = createSignal<Resolution<T>>()
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on([sessionID, lineage] as const, ([id, store]) => {
|
||||||
|
let stale = false
|
||||||
|
onCleanup(() => {
|
||||||
|
stale = true
|
||||||
|
})
|
||||||
|
if (cached()) {
|
||||||
|
setStatus({ id, store, state: "settled" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStatus({ id, store, state: "pending" })
|
||||||
|
store
|
||||||
|
.resolve(id)
|
||||||
|
.then(() => {
|
||||||
|
if (!stale) setStatus({ id, store, state: "settled" })
|
||||||
|
})
|
||||||
|
.catch((failure) => {
|
||||||
|
if (!stale) setStatus({ id, store, state: "failed", failure })
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return createMemo(() => {
|
||||||
|
const id = sessionID()
|
||||||
|
const value = cached()
|
||||||
|
if (value) return value
|
||||||
|
const state = status()
|
||||||
|
if (state?.id !== id || state.store !== lineage()) return undefined
|
||||||
|
if (state.state === "failed") throw state.failure
|
||||||
|
// The viewed session is pinned (DirectoryDataProvider, directory-layout.tsx)
|
||||||
|
// and pinned lineages are exempt from cache pruning, so a lineage missing
|
||||||
|
// after settlement means the session (or an ancestor) was deleted, possibly
|
||||||
|
// by another client. Match the resolve error so the boundary shows the
|
||||||
|
// session not found fallback.
|
||||||
|
if (state.state === "settled") throw sessionNotFoundError(id)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,20 @@ function unwrapNamedError(error: unknown): unknown {
|
||||||
return error
|
return error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Client-synthesized session not-found errors share one constructor and
|
||||||
|
// predicate so the message contract cannot drift between the sync store
|
||||||
|
// (server-session.ts), the route lineage (session-lineage.ts), and the
|
||||||
|
// not-found fallback matching (session.tsx).
|
||||||
|
const sessionNotFoundMessage = (sessionID: string) => `Session not found: ${sessionID}`
|
||||||
|
|
||||||
|
export function sessionNotFoundError(sessionID: string) {
|
||||||
|
return new Error(sessionNotFoundMessage(sessionID))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalSessionNotFoundError(error: unknown, sessionID: string) {
|
||||||
|
return error instanceof Error && error.message === sessionNotFoundMessage(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
export function isSessionNotFoundError(error: unknown, sessionID: string) {
|
export function isSessionNotFoundError(error: unknown, sessionID: string) {
|
||||||
const unwrapped = unwrapNamedError(error)
|
const unwrapped = unwrapNamedError(error)
|
||||||
if (typeof unwrapped !== "object" || unwrapped === null) return false
|
if (typeof unwrapped !== "object" || unwrapped === null) return false
|
||||||
|
|
|
||||||
231
packages/app/test-browser/session-lineage.test.ts
Normal file
231
packages/app/test-browser/session-lineage.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
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 } }
|
||||||
|
|
||||||
|
const lineageOf = (id: string): Lineage => ({ session: { id, directory: `/dir/${id}` } })
|
||||||
|
|
||||||
|
// 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]: lineageOf(id) })
|
||||||
|
deferred.get(id)?.resolve(undefined)
|
||||||
|
},
|
||||||
|
fail(id: string, error: unknown) {
|
||||||
|
deferred.get(id)?.reject(error)
|
||||||
|
// The real store does not cache failures: the inflight request entry is
|
||||||
|
// dropped on rejection so the next resolve retries (server-session.ts).
|
||||||
|
deferred.delete(id)
|
||||||
|
},
|
||||||
|
remove(id: string) {
|
||||||
|
const next = { ...cache() }
|
||||||
|
delete next[id]
|
||||||
|
setCache(next)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two microtask ticks: one for the resolve promise handed back by the fixture,
|
||||||
|
// one for the .then/.catch chain inside createSessionLineage.
|
||||||
|
const flush = async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// A resolution that fails while its session is unfocused must not leave a
|
||||||
|
// poisoned status behind: revisiting that session retries cleanly instead of
|
||||||
|
// rethrowing the stale failure before the retry can start.
|
||||||
|
test("revisiting a session whose resolution failed while unfocused retries cleanly", 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("resolve failed"))
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
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 lineage accessor is reactive: replacing the sync store (for example after
|
||||||
|
// the server context is rebuilt) must gate out the old store's status and
|
||||||
|
// re-resolve against the new one instead of fabricating a not-found.
|
||||||
|
test("re-resolves against a replaced lineage store", async () => {
|
||||||
|
await createRoot(async (dispose) => {
|
||||||
|
const first = createFixture()
|
||||||
|
const second = createFixture()
|
||||||
|
const [store, setStore] = createSignal(first.lineage)
|
||||||
|
const current = createSessionLineage(() => "ses_a", store)
|
||||||
|
|
||||||
|
await flush()
|
||||||
|
first.settle("ses_a")
|
||||||
|
await flush()
|
||||||
|
expect(current()?.session.id).toBe("ses_a")
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
setStore(second.lineage)
|
||||||
|
current()
|
||||||
|
}).not.toThrow()
|
||||||
|
await flush()
|
||||||
|
expect(second.resolves).toEqual(["ses_a"])
|
||||||
|
|
||||||
|
second.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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue