Skip to content

Commit fa8b9fd

Browse files
authored
Merge pull request #7399 from LibreSign/fix/7321-lazy-load-files-sidebar-tab
fix: lazy load files sidebar tab
2 parents 43ea40a + b3f5443 commit fa8b9fd

2 files changed

Lines changed: 162 additions & 22 deletions

File tree

src/tab.ts

Lines changed: 33 additions & 22 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, registerSidebarTab } 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) {
@@ -79,11 +77,13 @@ function mapNodeToFileInfo(node: SidebarNode = {}): FileInfo {
7977
interface LibreSignSidebarTabElement extends HTMLElement {
8078
_node?: SidebarNode
8179
_active?: boolean
80+
_vueApp?: VueApp<Element> | null
8281
_vueInstance?: TabComponentInstance | null
82+
_mountPromise?: Promise<void> | null
8383
node?: SidebarNode
8484
update(fileInfo: FileInfo): void
8585
setActive(active: boolean): Promise<void>
86-
mountVue(): void
86+
mountVue(): Promise<void>
8787
destroyVue(): void
8888
updateFromNode(): void
8989
}
@@ -98,11 +98,12 @@ function setupCustomElement() {
9898
class LibreSignSidebarTab extends HTMLElement implements LibreSignSidebarTabElement {
9999
_node?: SidebarNode
100100
_active?: boolean
101+
_vueApp?: VueApp<Element> | null
101102
_vueInstance?: TabComponentInstance | null
103+
_mountPromise?: Promise<void> | null
102104

103105
connectedCallback() {
104-
this.mountVue()
105-
this.updateFromNode()
106+
void this.mountVue()
106107
}
107108

108109
disconnectedCallback() {
@@ -132,35 +133,45 @@ function setupCustomElement() {
132133
}
133134
}
134135

135-
mountVue() {
136-
if (this._vueInstance) {
137-
return
136+
async mountVue() {
137+
if (this._vueInstance || this._mountPromise) {
138+
return this._mountPromise ?? Promise.resolve()
138139
}
139140

140-
const app = createApp(AppFilesTab)
141-
app.config.globalProperties.t = t
142-
app.config.globalProperties.n = n
143-
app.use(pinia)
141+
this._mountPromise = (async () => {
142+
const { default: AppFilesTab } = await import('./components/RightSidebar/AppFilesTab.vue')
143+
if (!this.isConnected || this._vueInstance) {
144+
return
145+
}
146+
147+
const app = createApp(AppFilesTab)
148+
app.config.globalProperties.t = t
149+
app.config.globalProperties.n = n
150+
app.use(pinia)
151+
152+
const element = document.createElement('div')
153+
this._vueApp = app
154+
this._vueInstance = app.mount(element)
155+
this.appendChild(element)
156+
this.updateFromNode()
157+
})().finally(() => {
158+
this._mountPromise = null
159+
})
144160

145-
const element = document.createElement('div')
146-
this._vueInstance = app.mount(element)
147-
this.appendChild(element)
161+
return this._mountPromise
148162
}
149163

150164
destroyVue() {
151-
if (this._vueInstance && this._vueInstance.$el) {
152-
// For Vue 3, we need to unmount the app
153-
// The best way would be to track the app instance
154-
this._vueInstance = null
155-
}
165+
this._vueApp?.unmount()
166+
this._vueApp = null
167+
this._vueInstance = null
156168
}
157169

158170
updateFromNode() {
159171
if (!this._vueInstance || !this._node) {
160172
return
161173
}
162174
const fileInfo = mapNodeToFileInfo(this._node)
163-
// Call update on the mounted component if it exists
164175
if (typeof this._vueInstance.update === 'function') {
165176
this._vueInstance.update(fileInfo)
166177
}

src/tests/tab.spec.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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 mockRegisterSidebarTab = 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+
registerSidebarTab: mockRegisterSidebarTab,
40+
}))
41+
42+
vi.mock('pinia', () => ({
43+
createPinia: mockCreatePinia,
44+
}))
45+
46+
vi.mock('vue', () => ({
47+
createApp: mockCreateApp,
48+
}))
49+
50+
vi.mock('../components/RightSidebar/AppFilesTab.vue', () => appFilesTabModuleLoaded())
51+
vi.mock('../../img/app-dark.svg?raw', () => ({ default: '<svg />' }))
52+
vi.mock('../style/icons.scss', () => ({}))
53+
54+
beforeAll(async () => {
55+
await import('../tab')
56+
})
57+
58+
beforeEach(() => {
59+
vi.clearAllMocks()
60+
window.OCA = window.OCA ?? {}
61+
window.OCA.Libresign = {}
62+
})
63+
64+
describe('tab.ts', () => {
65+
it('registers LibreSign sidebar tab on DOMContentLoaded', () => {
66+
window.dispatchEvent(new Event('DOMContentLoaded'))
67+
68+
expect(mockRegisterSidebarTab).toHaveBeenCalledOnce()
69+
const tabConfig = mockRegisterSidebarTab.mock.calls[0][0] as { id: string; tagName: string }
70+
expect(tabConfig.id).toBe('libresign')
71+
expect(tabConfig.tagName).toBe('libresign-files-sidebar-tab')
72+
})
73+
74+
it('enabled() returns false when certificate is not configured', () => {
75+
mockLoadState.mockReturnValue(false)
76+
window.dispatchEvent(new Event('DOMContentLoaded'))
77+
const tabConfig = mockRegisterSidebarTab.mock.calls[0][0] as {
78+
enabled: (context: { node: Record<string, unknown> }) => boolean
79+
}
80+
81+
expect(tabConfig.enabled({ node: { type: 'file', mimetype: 'application/pdf' } })).toBe(false)
82+
})
83+
84+
it('enabled() accepts signed folders and maps file info into OCA.Libresign', () => {
85+
mockLoadState.mockReturnValue(true)
86+
window.dispatchEvent(new Event('DOMContentLoaded'))
87+
const tabConfig = mockRegisterSidebarTab.mock.calls[0][0] as {
88+
enabled: (context: { node: Record<string, unknown> }) => boolean
89+
}
90+
91+
const enabled = tabConfig.enabled({
92+
node: {
93+
fileid: 101,
94+
basename: 'Signed',
95+
dirname: '/Documents',
96+
type: 'dir',
97+
attributes: {
98+
'libresign-signature-status': 'completed',
99+
},
100+
},
101+
})
102+
103+
expect(enabled).toBe(true)
104+
expect(window.OCA.Libresign.fileInfo).toMatchObject({
105+
id: 101,
106+
name: 'Signed',
107+
path: '/Documents',
108+
})
109+
})
110+
111+
it('lazy mounts Vue only when custom element is connected and unmounts on disconnect', async () => {
112+
window.dispatchEvent(new Event('DOMContentLoaded'))
113+
114+
const TabElement = window.customElements.get('libresign-files-sidebar-tab')
115+
expect(TabElement).toBeDefined()
116+
expect(mockCreateApp).not.toHaveBeenCalled()
117+
expect(appFilesTabModuleLoaded).not.toHaveBeenCalled()
118+
119+
const element = document.createElement('libresign-files-sidebar-tab')
120+
document.body.appendChild(element)
121+
122+
await vi.waitFor(() => expect(appFilesTabModuleLoaded).toHaveBeenCalledOnce())
123+
expect(mockCreateApp).toHaveBeenCalledOnce()
124+
expect(mockVueApp.mount).toHaveBeenCalledOnce()
125+
126+
element.remove()
127+
expect(mockVueApp.unmount).toHaveBeenCalledOnce()
128+
})
129+
})

0 commit comments

Comments
 (0)