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 class="flex-1 overflow-hidden relative">
<div <div
v-for="session in sessions.sessions" v-for="session in sessions.sessions"
:key="session.id" :key="session.key"
v-show="session.id === sessions.activeSessionId" v-show="session.id === sessions.activeSessionId"
class="absolute inset-0 flex" 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"> <div class="flex h-8 bg-gray-950 border-b border-gray-800 overflow-x-auto shrink-0">
<SessionTab <SessionTab
v-for="session in sessions.sessions" v-for="session in sessions.sessions"
:key="session.id" :key="session.key"
:session="session" :session="session"
:is-active="session.id === sessions.activeSessionId" :is-active="session.id === sessions.activeSessionId"
@activate="sessions.setActive(session.id)" @activate="sessions.setActive(session.id)"

View File

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

View File

@ -68,7 +68,7 @@ export function useTerminal() {
switch (msg.type) { switch (msg.type) {
case 'connected': case 'connected':
// Replace the pending placeholder with the real backend session // 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 // Send initial terminal size
ws!.send(JSON.stringify({ type: 'resize', sessionId: msg.sessionId, cols: term.cols, rows: term.rows })) ws!.send(JSON.stringify({ type: 'resize', sessionId: msg.sessionId, cols: term.cols, rows: term.rows }))
break break

View File

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

View File

@ -1,7 +1,8 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
interface Session { 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 hostId: number
hostName: string hostName: string
protocol: 'ssh' | 'rdp' protocol: 'ssh' | 'rdp'
@ -32,6 +33,8 @@ export const useSessionStore = defineStore('sessions', {
replaceSession(oldId: string, newSession: Session) { replaceSession(oldId: string, newSession: Session) {
const idx = this.sessions.findIndex(s => s.id === oldId) const idx = this.sessions.findIndex(s => s.id === oldId)
if (idx !== -1) { if (idx !== -1) {
// Preserve the stable key so Vue doesn't remount the component
newSession.key = this.sessions[idx].key
this.sessions[idx] = newSession this.sessions[idx] = newSession
} else { } else {
this.sessions.push(newSession) this.sessions.push(newSession)