fix: infinite remount loop — use stable key for session components

When replaceSession changed the session ID from pending-XXX to a
real UUID, Vue's :key="session.id" treated it as a new element,
destroyed and recreated TerminalInstance, which called connectToHost
again, got another UUID, replaced again — infinite loop.

Added a stable `key` field to sessions that never changes after
creation, used as the Vue :key instead of the mutable `id`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-14 01:51:31 -04:00
parent 4ccf138744
commit aa457b54d4
6 changed files with 10 additions and 4 deletions

View File

@ -41,7 +41,7 @@ function handleRdpClipboard(sessionId: string, text: string) {
<div class="flex-1 overflow-hidden relative">
<div
v-for="session in sessions.sessions"
:key="session.id"
:key="session.key"
v-show="session.id === sessions.activeSessionId"
class="absolute inset-0 flex"
>

View File

@ -8,7 +8,7 @@ const sessions = useSessionStore()
<div class="flex h-8 bg-gray-950 border-b border-gray-800 overflow-x-auto shrink-0">
<SessionTab
v-for="session in sessions.sessions"
:key="session.id"
:key="session.key"
:session="session"
:is-active="session.id === sessions.activeSessionId"
@activate="sessions.setActive(session.id)"

View File

@ -182,6 +182,7 @@ export function useRdp() {
// Wire tunnel callbacks
tunnel.onConnected = (_resolvedHostId: number, resolvedHostName: string) => {
sessions.addSession({
key: sessionId,
id: sessionId,
hostId,
hostName: resolvedHostName || hostName,

View File

@ -68,7 +68,7 @@ export function useTerminal() {
switch (msg.type) {
case 'connected':
// Replace the pending placeholder with the real backend session
sessions.replaceSession(pendingSessionId, { id: msg.sessionId, hostId, hostName, protocol, color, active: true })
sessions.replaceSession(pendingSessionId, { key: pendingSessionId, id: msg.sessionId, hostId, hostName, protocol, color, active: true })
// Send initial terminal size
ws!.send(JSON.stringify({ type: 'resize', sessionId: msg.sessionId, cols: term.cols, rows: term.rows }))
break

View File

@ -90,6 +90,7 @@ function connectHost(host: any) {
}
const pendingId = `pending-${Date.now()}`
sessions.addSession({
key: pendingId,
id: pendingId,
hostId: host.id,
hostName: host.name,
@ -107,6 +108,7 @@ function handleQuickConnect(params: { hostname: string; port: number; username:
: params.hostname
sessions.addSession({
key: sessionId,
id: sessionId,
hostId: null,
hostName: displayName,

View File

@ -1,7 +1,8 @@
import { defineStore } from 'pinia'
interface Session {
id: string // uuid from backend
key: string // stable Vue key — never changes after creation
id: string // uuid from backend (starts as pending-XXX, replaced with real UUID)
hostId: number
hostName: string
protocol: 'ssh' | 'rdp'
@ -32,6 +33,8 @@ export const useSessionStore = defineStore('sessions', {
replaceSession(oldId: string, newSession: Session) {
const idx = this.sessions.findIndex(s => s.id === oldId)
if (idx !== -1) {
// Preserve the stable key so Vue doesn't remount the component
newSession.key = this.sessions[idx].key
this.sessions[idx] = newSession
} else {
this.sessions.push(newSession)