Skip to content

Commit 133906b

Browse files
authored
Merge pull request #7400 from LibreSign/backport/7399/stable32
[stable32] fix: lazy load files sidebar tab
2 parents ca71c4b + 009cf99 commit 133906b

2 files changed

Lines changed: 172 additions & 11 deletions

File tree

src/tab.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,14 @@
44
*/
55

66
import { createPinia } from 'pinia'
7-
import { createApp } from 'vue'
7+
import { createApp, type App as VueApp } from 'vue'
88

99
import { loadState } from '@nextcloud/initial-state'
1010
import { t, n } from '@nextcloud/l10n'
1111
import { FileType } from '@nextcloud/files'
1212

1313
import LibreSignLogoDarkSvg from '../img/app-dark.svg?raw'
1414

15-
import AppFilesTab from './components/RightSidebar/AppFilesTab.vue'
16-
1715
import './style/icons.scss'
1816

1917
if (!window.OCA.Libresign) {
@@ -54,6 +52,7 @@ window.addEventListener('DOMContentLoaded', () => {
5452
const tabPinia = createPinia()
5553
let currentApp: ReturnType<typeof createApp> | null = null
5654
let currentInstance: TabComponentInstance | null = null
55+
let mountVersion = 0
5756

5857
sidebarService.registerTab(new sidebarService.Tab({
5958
id: 'libresign',
@@ -76,15 +75,23 @@ window.addEventListener('DOMContentLoaded', () => {
7675
},
7776
mount(el: HTMLElement, rawFileInfo: unknown) {
7877
const fileInfo = rawFileInfo as FileInfo
79-
currentApp = createApp(AppFilesTab)
80-
currentApp.config.globalProperties.t = t
81-
currentApp.config.globalProperties.n = n
82-
currentApp.use(tabPinia)
83-
currentInstance = currentApp.mount(el) as TabComponentInstance
84-
if (typeof currentInstance?.update === 'function') {
85-
currentInstance.update(fileInfo)
86-
}
8778
window.OCA.Libresign.fileInfo = fileInfo
79+
80+
const currentMountVersion = ++mountVersion
81+
void import('./components/RightSidebar/AppFilesTab.vue').then(({ default: AppFilesTab }) => {
82+
if (!el.isConnected || currentMountVersion !== mountVersion) {
83+
return
84+
}
85+
86+
currentApp = createApp(AppFilesTab)
87+
currentApp.config.globalProperties.t = t
88+
currentApp.config.globalProperties.n = n
89+
currentApp.use(tabPinia)
90+
currentInstance = currentApp.mount(el) as TabComponentInstance
91+
if (typeof currentInstance?.update === 'function') {
92+
currentInstance.update(fileInfo)
93+
}
94+
})
8895
},
8996
update(rawFileInfo: unknown) {
9097
const fileInfo = rawFileInfo as FileInfo
@@ -94,6 +101,7 @@ window.addEventListener('DOMContentLoaded', () => {
94101
window.OCA.Libresign.fileInfo = fileInfo
95102
},
96103
destroy() {
104+
mountVersion += 1
97105
if (currentApp) {
98106
currentApp.unmount()
99107
currentApp = null

src/tests/tab.spec.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2026 LibreSign contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const mockLoadState = vi.fn(() => true)
9+
const mockRegisterTab = vi.fn()
10+
const mockCreatePinia = vi.fn(() => ({ _id: 'pinia' }))
11+
12+
const mockMountedInstance = {
13+
update: vi.fn(),
14+
}
15+
16+
const mockVueApp = {
17+
config: { globalProperties: {} as Record<string, unknown> },
18+
use: vi.fn().mockReturnThis(),
19+
mount: vi.fn(() => mockMountedInstance),
20+
unmount: vi.fn(),
21+
}
22+
23+
const mockCreateApp = vi.fn(() => mockVueApp)
24+
const appFilesTabModuleLoaded = vi.fn(() => ({
25+
default: { name: 'AppFilesTabStub', template: '<div />' },
26+
}))
27+
28+
vi.mock('@nextcloud/initial-state', () => ({
29+
loadState: mockLoadState,
30+
}))
31+
32+
vi.mock('@nextcloud/l10n', () => ({
33+
t: (_app: string, text: string) => text,
34+
n: (_app: string, singular: string, _plural: string, _count: number) => singular,
35+
}))
36+
37+
vi.mock('@nextcloud/files', () => ({
38+
FileType: { Folder: 'dir' },
39+
}))
40+
41+
vi.mock('pinia', () => ({
42+
createPinia: mockCreatePinia,
43+
}))
44+
45+
vi.mock('vue', () => ({
46+
createApp: mockCreateApp,
47+
}))
48+
49+
vi.mock('../components/RightSidebar/AppFilesTab.vue', () => appFilesTabModuleLoaded())
50+
vi.mock('../../img/app-dark.svg?raw', () => ({ default: '<svg />' }))
51+
vi.mock('../style/icons.scss', () => ({}))
52+
53+
beforeAll(async () => {
54+
await import('../tab')
55+
})
56+
57+
beforeEach(() => {
58+
vi.clearAllMocks()
59+
window.OCA = window.OCA ?? {}
60+
window.OCA.Libresign = {}
61+
;(window.OCA as any).Files = {
62+
Sidebar: {
63+
registerTab: mockRegisterTab,
64+
open: vi.fn(),
65+
setActiveTab: vi.fn(),
66+
Tab: class MockSidebarTab {
67+
constructor(config: Record<string, unknown>) {
68+
return config
69+
}
70+
},
71+
},
72+
}
73+
})
74+
75+
describe('tab.ts', () => {
76+
it('registers LibreSign sidebar tab on DOMContentLoaded', () => {
77+
window.dispatchEvent(new Event('DOMContentLoaded'))
78+
79+
expect(mockRegisterTab).toHaveBeenCalledOnce()
80+
const tabConfig = mockRegisterTab.mock.calls[0][0] as { id: string; name: string }
81+
expect(tabConfig.id).toBe('libresign')
82+
expect(tabConfig.name).toBe('LibreSign')
83+
})
84+
85+
it('enabled() returns false when certificate is not configured', () => {
86+
mockLoadState.mockReturnValue(false)
87+
window.dispatchEvent(new Event('DOMContentLoaded'))
88+
const tabConfig = mockRegisterTab.mock.calls[0][0] as {
89+
enabled: (context: Record<string, unknown>) => boolean
90+
}
91+
92+
expect(tabConfig.enabled({ type: 'file', mimetype: 'application/pdf' })).toBe(false)
93+
})
94+
95+
it('enabled() accepts signed folders and maps file info into OCA.Libresign', () => {
96+
mockLoadState.mockReturnValue(true)
97+
window.dispatchEvent(new Event('DOMContentLoaded'))
98+
const tabConfig = mockRegisterTab.mock.calls[0][0] as {
99+
enabled: (context: Record<string, unknown>) => boolean
100+
update: (context: Record<string, unknown>) => void
101+
}
102+
103+
const fileInfo = {
104+
fileid: 101,
105+
basename: 'Signed',
106+
dirname: '/Documents',
107+
type: 'dir',
108+
attributes: {
109+
'libresign-signature-status': 'completed',
110+
},
111+
}
112+
113+
const enabled = tabConfig.enabled(fileInfo)
114+
tabConfig.update(fileInfo)
115+
116+
expect(enabled).toBe(true)
117+
expect(window.OCA.Libresign.fileInfo).toMatchObject({
118+
fileid: 101,
119+
basename: 'Signed',
120+
dirname: '/Documents',
121+
})
122+
})
123+
124+
it('lazy mounts Vue only when custom element is connected and unmounts on disconnect', async () => {
125+
window.dispatchEvent(new Event('DOMContentLoaded'))
126+
const tabConfig = mockRegisterTab.mock.calls[0][0] as {
127+
mount: (el: HTMLElement, rawFileInfo: Record<string, unknown>) => void
128+
destroy: () => void
129+
}
130+
expect(mockCreateApp).not.toHaveBeenCalled()
131+
expect(appFilesTabModuleLoaded).not.toHaveBeenCalled()
132+
133+
const element = document.createElement('div')
134+
document.body.appendChild(element)
135+
tabConfig.mount(element, {
136+
fileid: 101,
137+
basename: 'Signed',
138+
dirname: '/Documents',
139+
type: 'dir',
140+
attributes: {
141+
'libresign-signature-status': 'completed',
142+
},
143+
})
144+
145+
await vi.waitFor(() => expect(appFilesTabModuleLoaded).toHaveBeenCalledOnce())
146+
expect(mockCreateApp).toHaveBeenCalledOnce()
147+
expect(mockVueApp.mount).toHaveBeenCalledOnce()
148+
149+
tabConfig.destroy()
150+
expect(mockVueApp.unmount).toHaveBeenCalledOnce()
151+
element.remove()
152+
})
153+
})

0 commit comments

Comments
 (0)