Skip to content

Commit 85478ed

Browse files
committed
feat(handlers): add onInit() to define a handler's element when it is needed
Same shape as the files sidebar tabs: the viewer calls onInit() the first time it needs the element, and renders it once the promise resolved and the tag is defined. The view and what it imports stay out of the registration script that runs on every page. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com>
1 parent dc742e2 commit 85478ed

6 files changed

Lines changed: 187 additions & 14 deletions

File tree

‎README.md‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,30 +87,34 @@ element is mounted again for every file, which is why `onMounted` is enough here
8787

8888
#### 3. Register it
8989

90-
This script turns the component into a custom element and tells the viewer which
91-
files it takes:
90+
This script tells the viewer which files it takes, and how to define the element
91+
that shows them:
9292

9393
```ts
9494
// src/init-viewer.ts
9595
import { t } from '@nextcloud/l10n'
9696
import { registerHandler } from '@nextcloud/viewer'
97-
import { defineCustomElement } from 'vue'
98-
import NoteView from './views/NoteView.vue'
9997

10098
const tagname = 'myapp-note-view'
10199

102-
if (!window.customElements.get(tagname)) {
103-
window.customElements.define(tagname, defineCustomElement(NoteView, { shadowRoot: false }))
104-
}
105-
106100
registerHandler({
107101
id: 'myapp-notes',
108102
displayName: t('myapp', 'Notes'),
109103
tagname,
110104
enabled: (nodes) => nodes.every((node) => node.mime === 'application/x-myapp-note'),
105+
// Only called the first time a note is opened
106+
onInit: async () => {
107+
const { defineCustomElement } = await import('vue')
108+
const { default: NoteView } = await import('./views/NoteView.vue')
109+
window.customElements.define(tagname, defineCustomElement(NoteView, { shadowRoot: false }))
110+
},
111111
})
112112
```
113113

114+
`onInit()` keeps your view out of this script. The viewer calls it the first
115+
time it needs the element, and renders the element once the promise has resolved
116+
and the tag is defined.
117+
114118
Add it as an entry of your build, next to your other ones:
115119

116120
```js
@@ -122,9 +126,9 @@ export default createAppConfig({
122126
})
123127
```
124128

125-
It lands in `js/myapp-init-viewer.mjs`. This script runs on every page, so keep
126-
it small: if your view pulls in something heavy, have the element render a small
127-
wrapper that loads the real view with `defineAsyncComponent`.
129+
It lands in `js/myapp-init-viewer.mjs`. This script runs on every page, which is
130+
why the view itself is only imported in `onInit()`: the script stays a few lines however
131+
much the view imports.
128132

129133
#### 4. Load it on every page
130134

@@ -309,6 +313,11 @@ registerHandler({
309313
})
310314
```
311315

316+
Defining the element up front puts the view in the script that registers the
317+
handler, and that script runs on every page. To keep it out, leave the
318+
definition to `onInit()`, as the [tutorial](#3-register-it) does: the viewer
319+
calls it the first time it needs the element.
320+
312321
The full handler shape (see the `IHandler` interface):
313322

314323
| Field | Type | Required | Description |
@@ -322,6 +331,7 @@ The full handler shape (see the `IHandler` interface):
322331
| `preload` | `(node: File) => Promise<void>` | no | Preload data for neighbouring files |
323332
| `theme` | `'dark' \| 'light' \| 'default'` | no | Viewer modal theme |
324333
| `supportsEndToEndEncryption` | `boolean` | no | Whether the handler supports end-to-end encrypted files |
334+
| `onInit` | `() => Promise<void>` | no | Defines the element for `tagname`, called the first time it is needed |
325335

326336
Gotchas:
327337

‎__tests__/component/Viewer.spec.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,3 +722,76 @@ describe('opening over a viewer that is still open', () => {
722722
expect(onClose).toHaveBeenCalledOnce()
723723
})
724724
})
725+
726+
describe('a handler that defines its element in onInit()', () => {
727+
let count = 0
728+
729+
/**
730+
* A handler whose onInit() defines its element, under a tag no other test uses.
731+
*
732+
* @param before - What onInit() waits for before defining it
733+
*/
734+
function lazyHandler(before: () => Promise<void> = async () => {}) {
735+
const tagname = `oca-viewer-lazy-${++count}`
736+
const onInit = vi.fn(async () => {
737+
await before()
738+
customElements.define(tagname, class extends HTMLElement {})
739+
})
740+
return makeHandler({ id: tagname, tagname, enabled: () => true, onInit })
741+
}
742+
743+
it('renders the element only once onInit() has defined it', async () => {
744+
let resolve!: () => void
745+
const handler = lazyHandler(() => new Promise((r) => {
746+
resolve = r
747+
}))
748+
const { vm, renderedTags } = mountViewer([handler])
749+
const file = makeFile()
750+
await vm.open([file], file)
751+
await flushPromises()
752+
753+
// Rendered before it is defined, it would get its bindings as
754+
// attributes and lose them on upgrade
755+
expect(renderedTags()).not.toContain(handler.tagname)
756+
expect(customElements.get(handler.tagname)).toBeUndefined()
757+
758+
resolve()
759+
await flushPromises()
760+
761+
expect(customElements.get(handler.tagname)).toBeDefined()
762+
expect(renderedTags()).toContain(handler.tagname)
763+
})
764+
765+
it('calls onInit() once however many files open with it', async () => {
766+
const handler = lazyHandler()
767+
const { vm, renderedTags } = mountViewer([handler])
768+
const first = makeFile({ basename: 'first.txt' })
769+
const second = makeFile({ basename: 'second.txt' })
770+
await vm.open([first, second], first)
771+
await flushPromises()
772+
await vm.open([first, second], second)
773+
await flushPromises()
774+
775+
expect(handler.onInit).toHaveBeenCalledOnce()
776+
expect(renderedTags()).toContain(handler.tagname)
777+
})
778+
779+
it('shows the error when onInit() fails, and tries again on the next open', async () => {
780+
const handler = lazyHandler()
781+
vi.mocked(handler.onInit!).mockRejectedValueOnce(new Error('Failed to fetch dynamically imported module'))
782+
const { vm, errorText, renderedTags } = mountViewer([handler])
783+
const file = makeFile()
784+
await vm.open([file], file)
785+
await flushPromises()
786+
787+
expect(errorText()).toBe('Failed to fetch dynamically imported module')
788+
expect(renderedTags()).not.toContain(handler.tagname)
789+
790+
vm.close()
791+
await vm.open([file], file)
792+
await flushPromises()
793+
794+
expect(handler.onInit).toHaveBeenCalledTimes(2)
795+
expect(renderedTags()).toContain(handler.tagname)
796+
})
797+
})

‎__tests__/registerHandler.spec.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,13 @@ describe('registering a handler whose id is taken', () => {
4040
expect(warn).not.toHaveBeenCalled()
4141
})
4242
})
43+
44+
describe('a handler that defines its element in onInit()', () => {
45+
it('is refused when onInit is not a function', () => {
46+
expect(() => registerHandler(makeHandler({
47+
id: 'not-an-init',
48+
tagname: 'oca-viewer-not-an-init',
49+
onInit: 'later' as never,
50+
}))).toThrow('Handler onInit must be a function if provided')
51+
})
52+
})

‎lib/handlers.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,18 @@ export interface IHandler {
6262
*/
6363
preload?: (node: IFile) => Promise<void>
6464

65+
/**
66+
* Called the first time the viewer needs the element, to define it
67+
* (`customElements.define()` with `tagname`).
68+
*
69+
* The viewer waits for the returned promise and for the element to be
70+
* defined (`customElements.whenDefined()`) before rendering it, so the
71+
* view and everything it imports stay out of the registration script
72+
* that runs on every page. Leave it out when the element is already
73+
* defined by the time the viewer opens.
74+
*/
75+
onInit?: () => Promise<void>
76+
6577
/**
6678
* Viewer modal theme (one of 'dark', 'light', 'default')
6779
*/
@@ -303,6 +315,10 @@ function validateHandler(handler: IHandler): void {
303315
throw new Error('Handler preload must be a function if provided')
304316
}
305317

318+
if (handler.onInit && typeof handler.onInit !== 'function') {
319+
throw new Error('Handler onInit must be a function if provided')
320+
}
321+
306322
if (handler.theme && !['dark', 'light', 'default'].includes(handler.theme)) {
307323
throw new Error("Handler theme must be one of 'dark', 'light', 'default' if provided")
308324
}

‎lib/utils/customElements.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
5+
import type { IHandler } from '../handlers.ts'
6+
57
import { logger } from '../services/logger.ts'
68

79
/**
@@ -23,3 +25,29 @@ export function defineCustomElementOnce(tagname: string, constructor: CustomElem
2325

2426
window.customElements.define(tagname, constructor)
2527
}
28+
29+
/** The handlers being initialized, by tag, so each one is initialized once however often it is asked for */
30+
const initializing = new Map<string, Promise<void>>()
31+
32+
/**
33+
* Make sure a handler's element is defined, initializing the handler first if it has to.
34+
*
35+
* Resolves at once for an element that is defined already, or for a
36+
* handler without `onInit()`, which defines its element itself. An
37+
* `onInit()` that fails is forgotten, so the next open tries again.
38+
*
39+
* @param handler - The handler whose element is about to be rendered
40+
*/
41+
export function initHandlerElement(handler: IHandler): Promise<void> {
42+
if (handler.onInit === undefined || window.customElements.get(handler.tagname) !== undefined) {
43+
return Promise.resolve()
44+
}
45+
46+
let pending = initializing.get(handler.tagname)
47+
if (pending === undefined) {
48+
pending = handler.onInit().then(() => window.customElements.whenDefined(handler.tagname)).then(() => undefined)
49+
initializing.set(handler.tagname, pending)
50+
pending.catch(() => initializing.delete(handler.tagname))
51+
}
52+
return pending
53+
}

‎lib/views/Viewer.vue‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@
145145
<!-- eslint-disable vue/attribute-hyphenation -->
146146
<component
147147
:is="currentHandler?.tagname"
148-
v-if="currentFile"
148+
v-if="currentFile && elementReady(currentHandler)"
149149
:file="currentFile"
150150
:files="[]"
151151
:is-sidebar-shown="isSidebarShown"
@@ -156,7 +156,7 @@
156156
@errored="onError" />
157157
<component
158158
:is="comparisonHandler?.tagname"
159-
v-if="comparisonFile"
159+
v-if="comparisonFile && elementReady(comparisonHandler)"
160160
:file="comparisonFile"
161161
:files="[]"
162162
:is-sidebar-shown="isSidebarShown"
@@ -170,7 +170,7 @@
170170
<!-- Single file view -->
171171
<component
172172
:is="currentHandler?.tagname"
173-
v-else-if="currentFile"
173+
v-else-if="currentFile && elementReady(currentHandler)"
174174
v-show="!loading && !errorString"
175175
:key="`${currentFile.fileid}-${reloadKey}`"
176176
ref="handlerElement"
@@ -245,6 +245,7 @@ import { getHandlerForFile } from '../helpers/handlerHelper.ts'
245245
import { fetchFolderContent } from '../services/dav.ts'
246246
import { logger } from '../services/logger.ts'
247247
import { canDownload } from '../utils/canDownload.ts'
248+
import { initHandlerElement } from '../utils/customElements.ts'
248249
import { restoreTitle, setViewerTitle } from '../utils/documentTitle.ts'
249250
import { emittedValue, toError } from '../utils/handlerEvents.ts'
250251
import { t } from '../utils/l10n.ts'
@@ -353,6 +354,41 @@ const canOpenSidebar = computed(() => currentOptions.value.enableSidebar !== fal
353354
// Comparison context (compare API)
354355
const comparisonFile = ref<IFile>()
355356
const comparisonHandler = ref<IHandler>()
357+
358+
// Tags whose element a handler's onInit() has defined. The registry itself
359+
// is not reactive, so this is what tells the template it may render them.
360+
const loadedTags = ref(new Set<string>())
361+
362+
/**
363+
* Whether a handler's element can be rendered yet.
364+
*
365+
* Rendered before its tag is defined, the element gets its bindings as
366+
* attributes and loses them when it upgrades, so one that comes from
367+
* `onInit()` waits for it.
368+
*
369+
* @param handler - The handler about to be rendered
370+
*/
371+
function elementReady(handler?: IHandler): boolean {
372+
return handler !== undefined
373+
&& (handler.onInit === undefined || loadedTags.value.has(handler.tagname) || window.customElements.get(handler.tagname) !== undefined)
374+
}
375+
376+
watch([currentHandler, comparisonHandler], (handlers) => {
377+
for (const handler of handlers) {
378+
if (handler === undefined || elementReady(handler)) {
379+
continue
380+
}
381+
initHandlerElement(handler).then(() => {
382+
loadedTags.value = new Set([...loadedTags.value, handler.tagname])
383+
}, (error: unknown) => {
384+
logger.error('Could not initialize a handler', { handler: handler.id, error })
385+
onError(error)
386+
})
387+
}
388+
// Synchronous: closing and reopening with the same handler in one tick is a
389+
// change a deferred watcher would not see, and the retry after a failed
390+
// load would never happen
391+
}, { immediate: true, flush: 'sync' })
356392
const isComparing = computed(() => !!comparisonFile.value)
357393
358394
// Files actions rendered in the viewer menu (download, delete, …), linked to

0 commit comments

Comments
 (0)