Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions packages/hub-ui/src/client/components/views/ViewIframe.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE, REMOTE_CONNECTION_KEY } from
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef, watchEffect } from 'vue'
import { sharedStateToRef } from '../../state/docks'
import ViewAssetsError from './ViewAssetsError.vue'
import ViewIframeLoading from './ViewIframeLoading.vue'

const props = defineProps<{
context: DocksContext
Expand Down Expand Up @@ -52,13 +53,24 @@ const ADDRESS_BAR_HEIGHT = 40

const isLoading = ref(true)
const isIframeLoading = ref(false)
// Flips true once the pane is mounted so the hide/show effect can run — a plain
// `pane.isMounted` read isn't reactive.
const paneReady = ref(false)

// A devframe whose client assets are published as their own npm package
// answers with a fallback page when it can reach neither a local install nor
// the CDN they live on. That page reports itself over `postMessage`, so the
// failure renders as a hub panel — with the install command and a retry —
// rather than as a bare page inside the frame.
const assetsError = ref<RemoteAssetsErrorMessage | null>(null)

// The blank iframe paints white while its content loads, so a placeholder is
// only useful when the pane steps aside (`pane.hide()`) to reveal it — the same
// layering trick `ViewAssetsError` relies on. Show it during the initial load
// and any hard navigation/refresh, but never on top of the assets-error panel.
const showLoadingPlaceholder = computed(
() => !assetsError.value && (isLoading.value || isIframeLoading.value),
)
const viewFrame = useTemplateRef<HTMLDivElement>('viewFrame')
const urlInputRef = useTemplateRef<HTMLInputElement>('urlInput')

Expand Down Expand Up @@ -236,6 +248,10 @@ onMounted(() => {

if (existed)
updateCurrentUrl()
else
// A freshly created pane is loading its initial content — reflect it so the
// placeholder covers the first paint, not just later navigations.
isIframeLoading.value = true

// Listen for iframe load events
onIframeLoad = () => {
Expand Down Expand Up @@ -268,19 +284,23 @@ onMounted(() => {
})

// The iframe lives in its own layer stacked over this view, so the error
// panel is only visible once the pane steps aside. `hide()` keeps the frame
// alive (and its state intact) for the retry.
// panel and the loading placeholder are only visible once the pane steps
// aside. `hide()` keeps the frame alive (and its state intact) so the content
// keeps loading behind the placeholder and survives a retry.
watchEffect(() => {
if (assetsError.value)
if (!paneReady.value)
return
if (assetsError.value || isIframeLoading.value)
pane.hide()
else if (pane.isMounted)
else
pane.show()
})

window.addEventListener('message', onWindowMessage)

pane.mount(viewFrame.value!)
isLoading.value = false
paneReady.value = true
nextTick(() => {
pane.update()
})
Expand Down Expand Up @@ -364,9 +384,7 @@ onUnmounted(() => {
ref="viewFrame"
class="devframes-view-iframe relative w-full h-full flex-1 items-center justify-center"
>
<div v-if="isLoading" class="op50 z--1">
Loading iframe...
</div>
<ViewIframeLoading v-if="showLoadingPlaceholder" />
<ViewAssetsError
v-if="assetsError"
:error="assetsError"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { h } from 'vue'
import ViewIframeLoading from './ViewIframeLoading.vue'

// The placeholder fills its positioned parent (`absolute inset-0`), so the
// stage mirrors the iframe view frame it renders into at runtime.
function stage(children: any) {
return h('div', { class: 'relative h-100 bg-base color-base border border-base rounded-lg overflow-hidden font-sans' }, children)
}

const meta = {
title: 'Views/IframeLoading',
component: ViewIframeLoading,
tags: ['autodocs'],
parameters: {
docs: {
description: {
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.',
},
},
},
} satisfies Meta

export default meta
type Story = StoryObj

export const Loading: Story = {
render: () => ({
setup: () => () => stage(h(ViewIframeLoading)),
}),
}
15 changes: 15 additions & 0 deletions packages/hub-ui/src/client/components/views/ViewIframeLoading.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script setup lang="ts">
// Placeholder shown while an iframe view loads its content. A blank iframe
// paints white during load, so this is only visible once the pane steps aside
// (`pane.hide()` in `ViewIframe`) — the same layering trick `ViewAssetsError`
// relies on. It covers the initial load and any hard navigation/refresh.
</script>

<template>
<div class="devframes-view-iframe-loading absolute inset-0 flex flex-col items-center justify-center gap-2 bg-base">
<div class="i-ph:circle-notch-duotone animate-spin text-3xl color-faint" />
<div class="text-sm color-muted">
Loading…
</div>
</div>
</template>
4 changes: 4 additions & 0 deletions packages/hub-ui/src/client/stories/mock-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ function createMockRpc(

const rpc = {
events,
// Server-advertised connection metadata. Stories have no live server, so
// advertise the `static` backend with no `configs` — the context reads
// `connectionMeta.configs?.ui?...` optionally, so an empty meta is enough.
connectionMeta: { backend: 'static' as const },
get isTrusted() {
return trusted
},
Expand Down
Loading