From fce6fb820c28cbfbe2833fcc266eda77e89a5367 Mon Sep 17 00:00:00 2001 From: 6543 <6543@obermui.de> Date: Wed, 15 Jul 2026 21:22:46 +0200 Subject: [PATCH] WebUI: unregister scaffold Tab on unmount and stable Tab order (#6842) Tabs register themselves into the Scaffold's tabs list on mount but never remove their entry on unmount. Since param-only navigation (e.g. the superseded-by link, repo-to-repo links) reuses the wrapper and its Scaffold, a conditionally rendered tab like the pipeline *Errors* tab or the repo *Pull requests* tab stayed visible after its condition turned false, linking to an empty page. Fix: remove the entry in `onBeforeUnmount`. The mount-time dedup guarantees at most one entry per route, so removal by route is safe. Includes lifecycle tests (register, dedup, unregister, re-register). Co-authored-by: Claude --- .../components/layout/scaffold/Tab.test.ts | 168 ++++++++++++++++++ web/src/components/layout/scaffold/Tab.vue | 70 +++++++- web/src/compositions/useTabs.ts | 1 + 3 files changed, 232 insertions(+), 7 deletions(-) create mode 100644 web/src/components/layout/scaffold/Tab.test.ts diff --git a/web/src/components/layout/scaffold/Tab.test.ts b/web/src/components/layout/scaffold/Tab.test.ts new file mode 100644 index 000000000..d6a0066d7 --- /dev/null +++ b/web/src/components/layout/scaffold/Tab.test.ts @@ -0,0 +1,168 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; + +import type { Tab as TabType } from '~/compositions/useTabs'; + +import Tab from './Tab.vue'; + +async function mountConditionalTab() { + const visible = ref(true); + const tabs = ref([]); + + const host = defineComponent({ + setup() { + return () => + h('div', [ + visible.value + ? h(Tab, { + to: { name: 'repo-pipeline-errors' }, + title: 'Errors', + icon: 'alert', + }) + : null, + ]); + }, + }); + + mount(host, { + global: { + provide: { tabs }, + }, + }); + await nextTick(); + + return { visible, tabs }; +} + +describe('tab', () => { + it('registers itself on mount', async () => { + const { tabs } = await mountConditionalTab(); + + expect(tabs.value).toHaveLength(1); + expect(tabs.value[0].title).toBe('Errors'); + }); + + it('does not register the same route twice', async () => { + const duplicateVisible = ref(true); + const tabs = ref([]); + + const host = defineComponent({ + setup() { + return () => + h('div', [ + h(Tab, { to: { name: 'repo-pipeline-errors' }, title: 'Errors' }), + duplicateVisible.value ? h(Tab, { to: { name: 'repo-pipeline-errors' }, title: 'Errors' }) : null, + ]); + }, + }); + + mount(host, { + global: { + provide: { tabs }, + }, + }); + await nextTick(); + + expect(tabs.value).toHaveLength(1); + + // the second instance was skipped by the dedup, so its unmount must not + // remove the entry registered by the first, still-mounted instance + duplicateVisible.value = false; + await nextTick(); + + expect(tabs.value).toHaveLength(1); + }); + + it('keeps the tab when the registering duplicate unmounts before the skipped one', async () => { + const firstVisible = ref(true); + const secondVisible = ref(true); + const tabs = ref([]); + + const host = defineComponent({ + setup() { + return () => + h('div', [ + firstVisible.value ? h(Tab, { to: { name: 'repo-pipeline-errors' }, title: 'Errors' }) : null, + secondVisible.value ? h(Tab, { to: { name: 'repo-pipeline-errors' }, title: 'Errors' }) : null, + ]); + }, + }); + + mount(host, { + global: { + provide: { tabs }, + }, + }); + await nextTick(); + + expect(tabs.value).toHaveLength(1); + + // the registering instance goes away, but a matching instance is still + // mounted, so the shared tab must survive + firstVisible.value = false; + await nextTick(); + + expect(tabs.value).toHaveLength(1); + expect(tabs.value[0].title).toBe('Errors'); + + // once the last matching instance unmounts, the tab must disappear + secondVisible.value = false; + await nextTick(); + + expect(tabs.value).toHaveLength(0); + }); + + it('unregisters itself on unmount', async () => { + const { visible, tabs } = await mountConditionalTab(); + + expect(tabs.value).toHaveLength(1); + + visible.value = false; + await nextTick(); + + expect(tabs.value).toHaveLength(0); + }); + + it('registers again after being re-rendered', async () => { + const { visible, tabs } = await mountConditionalTab(); + + visible.value = false; + await nextTick(); + visible.value = true; + await nextTick(); + + expect(tabs.value).toHaveLength(1); + expect(tabs.value[0].title).toBe('Errors'); + }); + + it('keeps template order when a tab mounts later than its siblings', async () => { + const middleVisible = ref(false); + const tabs = ref([]); + + const host = defineComponent({ + setup() { + return () => + h('div', [ + h(Tab, { to: { name: 'repo-pipeline' }, title: 'Tasks' }), + middleVisible.value ? h(Tab, { to: { name: 'repo-pipeline-errors' }, title: 'Errors' }) : null, + h(Tab, { to: { name: 'repo-pipeline-config' }, title: 'Config' }), + ]); + }, + }); + + mount(host, { + global: { + provide: { tabs }, + }, + }); + await nextTick(); + + expect(tabs.value.map(({ title }) => title)).toStrictEqual(['Tasks', 'Config']); + + middleVisible.value = true; + await nextTick(); + + expect(tabs.value.map(({ title }) => title)).toStrictEqual(['Tasks', 'Errors', 'Config']); + }); +}); diff --git a/web/src/components/layout/scaffold/Tab.vue b/web/src/components/layout/scaffold/Tab.vue index b180c2c59..03362f6a8 100644 --- a/web/src/components/layout/scaffold/Tab.vue +++ b/web/src/components/layout/scaffold/Tab.vue @@ -1,12 +1,19 @@ - + - + + diff --git a/web/src/compositions/useTabs.ts b/web/src/compositions/useTabs.ts index 4ebad6965..fd919c1e4 100644 --- a/web/src/compositions/useTabs.ts +++ b/web/src/compositions/useTabs.ts @@ -12,6 +12,7 @@ export interface Tab { icon?: IconNames; iconClass?: string; matchChildren?: boolean; + anchor?: HTMLElement; } export function useTabsProvider() {