Skip to content

Commit c408e17

Browse files
authored
feat(hub-ui): add loading placeholder for iframes (#247)
1 parent efce507 commit c408e17

4 files changed

Lines changed: 75 additions & 7 deletions

File tree

packages/hub-ui/src/client/components/views/ViewIframe.vue

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE, REMOTE_CONNECTION_KEY } from
88
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef, watchEffect } from 'vue'
99
import { sharedStateToRef } from '../../state/docks'
1010
import ViewAssetsError from './ViewAssetsError.vue'
11+
import ViewIframeLoading from './ViewIframeLoading.vue'
1112
1213
const props = defineProps<{
1314
context: DocksContext
@@ -52,13 +53,24 @@ const ADDRESS_BAR_HEIGHT = 40
5253
5354
const isLoading = ref(true)
5455
const isIframeLoading = ref(false)
56+
// Flips true once the pane is mounted so the hide/show effect can run — a plain
57+
// `pane.isMounted` read isn't reactive.
58+
const paneReady = ref(false)
5559
5660
// A devframe whose client assets are published as their own npm package
5761
// answers with a fallback page when it can reach neither a local install nor
5862
// the CDN they live on. That page reports itself over `postMessage`, so the
5963
// failure renders as a hub panel — with the install command and a retry —
6064
// rather than as a bare page inside the frame.
6165
const assetsError = ref<RemoteAssetsErrorMessage | null>(null)
66+
67+
// The blank iframe paints white while its content loads, so a placeholder is
68+
// only useful when the pane steps aside (`pane.hide()`) to reveal it — the same
69+
// layering trick `ViewAssetsError` relies on. Show it during the initial load
70+
// and any hard navigation/refresh, but never on top of the assets-error panel.
71+
const showLoadingPlaceholder = computed(
72+
() => !assetsError.value && (isLoading.value || isIframeLoading.value),
73+
)
6274
const viewFrame = useTemplateRef<HTMLDivElement>('viewFrame')
6375
const urlInputRef = useTemplateRef<HTMLInputElement>('urlInput')
6476
@@ -245,6 +257,10 @@ onMounted(() => {
245257
246258
if (existed)
247259
updateCurrentUrl()
260+
else
261+
// A freshly created pane is loading its initial content — reflect it so the
262+
// placeholder covers the first paint, not just later navigations.
263+
isIframeLoading.value = true
248264
249265
// Persist this dock's live route while it is the selected one, so the next
250266
// reload can restore it. Only the selected dock writes, so switching docks
@@ -286,19 +302,23 @@ onMounted(() => {
286302
})
287303
288304
// The iframe lives in its own layer stacked over this view, so the error
289-
// panel is only visible once the pane steps aside. `hide()` keeps the frame
290-
// alive (and its state intact) for the retry.
305+
// panel and the loading placeholder are only visible once the pane steps
306+
// aside. `hide()` keeps the frame alive (and its state intact) so the content
307+
// keeps loading behind the placeholder and survives a retry.
291308
watchEffect(() => {
292-
if (assetsError.value)
309+
if (!paneReady.value)
310+
return
311+
if (assetsError.value || isIframeLoading.value)
293312
pane.hide()
294-
else if (pane.isMounted)
313+
else
295314
pane.show()
296315
})
297316
298317
window.addEventListener('message', onWindowMessage)
299318
300319
pane.mount(viewFrame.value!)
301320
isLoading.value = false
321+
paneReady.value = true
302322
nextTick(() => {
303323
pane.update()
304324
})
@@ -382,9 +402,7 @@ onUnmounted(() => {
382402
ref="viewFrame"
383403
class="devframes-view-iframe relative w-full h-full flex-1 items-center justify-center"
384404
>
385-
<div v-if="isLoading" class="op50 z--1">
386-
Loading iframe...
387-
</div>
405+
<ViewIframeLoading v-if="showLoadingPlaceholder" />
388406
<ViewAssetsError
389407
v-if="assetsError"
390408
:error="assetsError"
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
2+
import { h } from 'vue'
3+
import ViewIframeLoading from './ViewIframeLoading.vue'
4+
5+
// The placeholder fills its positioned parent (`absolute inset-0`), so the
6+
// stage mirrors the iframe view frame it renders into at runtime.
7+
function stage(children: any) {
8+
return h('div', { class: 'relative h-100 bg-base color-base border border-base rounded-lg overflow-hidden font-sans' }, children)
9+
}
10+
11+
const meta = {
12+
title: 'Views/IframeLoading',
13+
component: ViewIframeLoading,
14+
tags: ['autodocs'],
15+
parameters: {
16+
docs: {
17+
description: {
18+
component: 'Shown over an iframe view while it loads its content. A blank iframe paints white during load, so `ViewIframe` reveals this placeholder by hiding the pane — the same layering trick as the assets-error panel. It covers the initial load and any hard navigation or refresh.',
19+
},
20+
},
21+
},
22+
} satisfies Meta
23+
24+
export default meta
25+
type Story = StoryObj
26+
27+
export const Loading: Story = {
28+
render: () => ({
29+
setup: () => () => stage(h(ViewIframeLoading)),
30+
}),
31+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script setup lang="ts">
2+
// Placeholder shown while an iframe view loads its content. A blank iframe
3+
// paints white during load, so this is only visible once the pane steps aside
4+
// (`pane.hide()` in `ViewIframe`) — the same layering trick `ViewAssetsError`
5+
// relies on. It covers the initial load and any hard navigation/refresh.
6+
</script>
7+
8+
<template>
9+
<div class="devframes-view-iframe-loading absolute inset-0 flex flex-col items-center justify-center gap-2 bg-base">
10+
<div class="i-ph:circle-notch-duotone animate-spin text-3xl color-faint" />
11+
<div class="text-sm color-muted">
12+
Loading…
13+
</div>
14+
</div>
15+
</template>

packages/hub-ui/src/client/stories/mock-context.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ function createMockRpc(
6161

6262
const rpc = {
6363
events,
64+
// Server-advertised connection metadata. Stories have no live server, so
65+
// advertise the `static` backend with no `configs` — the context reads
66+
// `connectionMeta.configs?.ui?...` optionally, so an empty meta is enough.
67+
connectionMeta: { backend: 'static' as const },
6468
get isTrusted() {
6569
return trusted
6670
},

0 commit comments

Comments
 (0)