Skip to content

Commit 773f1de

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/frame-location-sync
# Conflicts: # packages/hub-ui/src/client/components/views/ViewIframe.vue
2 parents a6f34ca + c408e17 commit 773f1de

14 files changed

Lines changed: 179 additions & 92 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,9 @@ jobs:
1414
uses: sxzz/workflows/.github/workflows/unit-test.yml@main
1515
with:
1616
build: pnpm run ci:build
17-
# The Build step above already produced a fresh dist/ for this exact
18-
# checkout, so skip `test`'s own `build && vitest` - running plain
19-
# vitest halves the number of full-monorepo `turbo run build` passes
20-
# per job, which is where the flaky Windows native-toolchain crash
21-
# (see scripts/ci-retry.ts) shows up.
2217
test: pnpm exec vitest
2318
lint: pnpm run lint && pnpm run knip
19+
build-for-lint: true
2420

2521
e2e:
2622
runs-on: ubuntu-latest

packages/devframe/src/rpc/wire-codec.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { strictJsonStringify, STRUCTURED_CLONE_PREFIX } from './serialization'
66
* The per-connection `serialize`/`deserialize` pair for a live RPC wire.
77
*
88
* @internal
9-
* implementations; not part of the stable public API.
109
*/
1110
export interface RpcWireCodec {
1211
serialize: (msg: any) => string
@@ -25,7 +24,6 @@ const EMPTY_WIRE_DEFS: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonS
2524
* don't collide across connections.
2625
*
2726
* @internal
28-
* implementations; not part of the stable public API.
2927
*/
3028
export function createRpcWireCodec(
3129
definitions: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerializable'>> = EMPTY_WIRE_DEFS,
@@ -72,7 +70,6 @@ export function createRpcWireCodec(
7270
* handed to birpc proper.
7371
*
7472
* @internal
75-
* implementations; not part of the stable public API.
7673
*/
7774
export function peekRpcWireFrame(raw: string): { t?: string, i?: string } {
7875
try {

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

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { DEVFRAME_REMOTE_ASSETS_ERROR_MESSAGE_TYPE } from '@devframes/hub/consta
99
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef, watchEffect } from 'vue'
1010
import { sharedStateToRef } from '../../state/docks'
1111
import ViewAssetsError from './ViewAssetsError.vue'
12+
import ViewIframeLoading from './ViewIframeLoading.vue'
1213
1314
const props = defineProps<{
1415
context: DocksContext
@@ -25,13 +26,24 @@ const ADDRESS_BAR_HEIGHT = 40
2526
2627
const isLoading = ref(true)
2728
const isIframeLoading = ref(false)
29+
// Flips true once the pane is mounted so the hide/show effect can run — a plain
30+
// `pane.isMounted` read isn't reactive.
31+
const paneReady = ref(false)
2832
2933
// A devframe whose client assets are published as their own npm package
3034
// answers with a fallback page when it can reach neither a local install nor
3135
// the CDN they live on. That page reports itself over `postMessage`, so the
3236
// failure renders as a hub panel — with the install command and a retry —
3337
// rather than as a bare page inside the frame.
3438
const assetsError = ref<RemoteAssetsErrorMessage | null>(null)
39+
40+
// The blank iframe paints white while its content loads, so a placeholder is
41+
// only useful when the pane steps aside (`pane.hide()`) to reveal it — the same
42+
// layering trick `ViewAssetsError` relies on. Show it during the initial load
43+
// and any hard navigation/refresh, but never on top of the assets-error panel.
44+
const showLoadingPlaceholder = computed(
45+
() => !assetsError.value && (isLoading.value || isIframeLoading.value),
46+
)
3547
const viewFrame = useTemplateRef<HTMLDivElement>('viewFrame')
3648
const urlInputRef = useTemplateRef<HTMLInputElement>('urlInput')
3749
@@ -210,7 +222,9 @@ onMounted(() => {
210222
// Follow the frame wherever it goes — a document load, but also an SPA
211223
// router's `pushState`/`replaceState` and back/forward, none of which fire
212224
// `load`. `currentUrl` is the single source the address bar renders and the
213-
// session route persists, so tracking it here keeps both live.
225+
// session route persists, so tracking it here keeps both live. Reattaching
226+
// to an already-live pane reports its current href immediately if it moved
227+
// on since the last time this view watched it.
214228
stopLocationWatch = watchFrameLocation({
215229
iframe,
216230
initial: currentUrl.value,
@@ -219,6 +233,11 @@ onMounted(() => {
219233
},
220234
})
221235
236+
if (!existed)
237+
// A freshly created pane is loading its initial content — reflect it so the
238+
// placeholder covers the first paint, not just later navigations.
239+
isIframeLoading.value = true
240+
222241
// Persist this dock's live route while it is the selected one, so the next
223242
// reload can restore it. Only the selected dock writes, so switching docks
224243
// never overwrites another's saved route.
@@ -258,19 +277,23 @@ onMounted(() => {
258277
})
259278
260279
// The iframe lives in its own layer stacked over this view, so the error
261-
// panel is only visible once the pane steps aside. `hide()` keeps the frame
262-
// alive (and its state intact) for the retry.
280+
// panel and the loading placeholder are only visible once the pane steps
281+
// aside. `hide()` keeps the frame alive (and its state intact) so the content
282+
// keeps loading behind the placeholder and survives a retry.
263283
watchEffect(() => {
264-
if (assetsError.value)
284+
if (!paneReady.value)
285+
return
286+
if (assetsError.value || isIframeLoading.value)
265287
pane.hide()
266-
else if (pane.isMounted)
288+
else
267289
pane.show()
268290
})
269291
270292
window.addEventListener('message', onWindowMessage)
271293
272294
pane.mount(viewFrame.value!)
273295
isLoading.value = false
296+
paneReady.value = true
274297
nextTick(() => {
275298
pane.update()
276299
})
@@ -358,9 +381,7 @@ onUnmounted(() => {
358381
ref="viewFrame"
359382
class="devframes-view-iframe relative w-full h-full flex-1 items-center justify-center"
360383
>
361-
<div v-if="isLoading" class="op50 z--1">
362-
Loading iframe...
363-
</div>
384+
<ViewIframeLoading v-if="showLoadingPlaceholder" />
364385
<ViewAssetsError
365386
v-if="assetsError"
366387
: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
},

plugins/data-inspector/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,7 @@
6262
}
6363
},
6464
"dependencies": {
65-
"cac": "catalog:deps",
66-
"jora": "catalog:deps"
65+
"cac": "catalog:deps"
6766
},
6867
"devDependencies": {
6968
"@antfu/design": "catalog:frontend",
@@ -79,6 +78,7 @@
7978
"devframe": "workspace:*",
8079
"dompurify": "catalog:frontend",
8180
"floating-vue": "catalog:frontend",
81+
"jora": "catalog:inlined",
8282
"reka-ui": "catalog:frontend",
8383
"splitpanes": "catalog:frontend",
8484
"storybook": "catalog:storybook",

plugins/data-inspector/src/engine/query-engine.ts

Lines changed: 67 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,20 @@
99
* forms (`{ $type: 'Map', value }`), keeping queries portable;
1010
* - suggestions come from jora's stat mode, flattened into plain
1111
* RPC-safe completion items.
12+
*
13+
* jora itself loads lazily, on the first query: `import('jora')` only runs
14+
* once `runQuery`/`runQueryAtPath`/`suggest` are actually called, so simply
15+
* registering the data-inspector's RPC functions (which happens on every
16+
* host that sets it up, whether or not anyone opens the panel) never pays
17+
* for parsing jora. jora is a `devDependency` (`catalog:inlined` in the
18+
* workspace catalog) rather than a regular `dependency`, so tsdown vendors
19+
* it straight into this package's own `dist` on both the node and browser
20+
* builds — the on-demand `import()` resolves a local chunk, and neither
21+
* side needs consumers to install jora themselves.
1222
*/
23+
import type { Jora } from 'jora'
1324
import type { NodePath, QueryOutcome, SuggestItem, SuggestOutcome } from './contract'
1425
import type { NormalizeOptions } from './normalize'
15-
import jora from 'jora'
1626
import { navigate, normalize } from './normalize'
1727

1828
export type { SuggestItem, SuggestOutcome } from './contract'
@@ -45,51 +55,62 @@ function isSetLike(v: unknown): v is Set<unknown> {
4555
&& typeof (v as Map<unknown, unknown>).get !== 'function'
4656
}
4757

48-
const createQuery = jora.setup({
49-
methods: {
50-
/** Map(-like or normalized tag) -> plain object (string-coerced keys). */
51-
fromMap: (v) => {
52-
if (isMapLike(v))
53-
return Object.fromEntries(v.entries())
54-
if (isMapTag(v))
55-
return v.value ?? Object.fromEntries((v.entries ?? []).map(e => [String(e.key), e.value]))
56-
return v
57-
},
58-
/** Map(-like or normalized tag) -> [{ key, value }] preserving key identity. */
59-
mapEntries: (v) => {
60-
if (isMapLike(v))
61-
return [...v.entries()].map(([key, value]) => ({ key, value }))
62-
if (isMapTag(v)) {
63-
if (v.entries)
64-
return v.entries
65-
return Object.entries(v.value ?? {}).map(([key, value]) => ({ key, value }))
66-
}
67-
return []
68-
},
69-
/** Set(-like or normalized tag) -> array. */
70-
fromSet: (v) => {
71-
if (isSetLike(v))
72-
return [...v]
73-
if (isSetTag(v))
74-
return v.values ?? []
75-
return v
76-
},
77-
/** Constructor name of any value. */
78-
typeOf: (v) => {
79-
if (v === null)
80-
return 'null'
81-
if (typeof v !== 'object')
82-
return typeof v
83-
return (v as object).constructor?.name ?? 'Object'
58+
type CreateQuery = ReturnType<Jora['setup']>
59+
60+
/**
61+
* jora loads on first use and is cached for the process lifetime — a single
62+
* `import('jora')` + `setup()`, however many queries follow.
63+
*/
64+
let createQueryPromise: Promise<CreateQuery> | undefined
65+
66+
function getCreateQuery(): Promise<CreateQuery> {
67+
return createQueryPromise ??= import('jora').then(({ default: jora }) => jora.setup({
68+
methods: {
69+
/** Map(-like or normalized tag) -> plain object (string-coerced keys). */
70+
fromMap: (v) => {
71+
if (isMapLike(v))
72+
return Object.fromEntries(v.entries())
73+
if (isMapTag(v))
74+
return v.value ?? Object.fromEntries((v.entries ?? []).map(e => [String(e.key), e.value]))
75+
return v
76+
},
77+
/** Map(-like or normalized tag) -> [{ key, value }] preserving key identity. */
78+
mapEntries: (v) => {
79+
if (isMapLike(v))
80+
return [...v.entries()].map(([key, value]) => ({ key, value }))
81+
if (isMapTag(v)) {
82+
if (v.entries)
83+
return v.entries
84+
return Object.entries(v.value ?? {}).map(([key, value]) => ({ key, value }))
85+
}
86+
return []
87+
},
88+
/** Set(-like or normalized tag) -> array. */
89+
fromSet: (v) => {
90+
if (isSetLike(v))
91+
return [...v]
92+
if (isSetTag(v))
93+
return v.values ?? []
94+
return v
95+
},
96+
/** Constructor name of any value. */
97+
typeOf: (v) => {
98+
if (v === null)
99+
return 'null'
100+
if (typeof v !== 'object')
101+
return typeof v
102+
return (v as object).constructor?.name ?? 'Object'
103+
},
104+
/** All own keys (incl. non-enumerable), as strings. */
105+
ownKeys: v => (v && typeof v === 'object') ? Reflect.ownKeys(v).map(String) : [],
84106
},
85-
/** All own keys (incl. non-enumerable), as strings. */
86-
ownKeys: v => (v && typeof v === 'object') ? Reflect.ownKeys(v).map(String) : [],
87-
},
88-
})
107+
}))
108+
}
89109

90-
export function runQuery(target: unknown, query: string, options?: NormalizeOptions): QueryOutcome {
110+
export async function runQuery(target: unknown, query: string, options?: NormalizeOptions): Promise<QueryOutcome> {
91111
try {
92112
const started = performance.now()
113+
const createQuery = await getCreateQuery()
93114
const raw = createQuery(query)(target)
94115
const queryMs = Math.round((performance.now() - started) * 100) / 100
95116
const { data, stats } = normalize(raw, options)
@@ -110,9 +131,10 @@ export function runQuery(target: unknown, query: string, options?: NormalizeOpti
110131
* 'depth'` marker the client is expanding, so the same filter options must be
111132
* threaded through (they shift array indices and drop keys).
112133
*/
113-
export function runQueryAtPath(target: unknown, query: string, path: NodePath, options?: NormalizeOptions): QueryOutcome {
134+
export async function runQueryAtPath(target: unknown, query: string, path: NodePath, options?: NormalizeOptions): Promise<QueryOutcome> {
114135
try {
115136
const started = performance.now()
137+
const createQuery = await getCreateQuery()
116138
const raw = createQuery(query)(target)
117139
const node = navigate(raw, path, options)
118140
const queryMs = Math.round((performance.now() - started) * 100) / 100
@@ -140,9 +162,10 @@ interface JoraStatEntry {
140162
* its candidates in a nested `suggestions` array — flattened here into plain,
141163
* RPC-safe completion items.
142164
*/
143-
export function suggest(target: unknown, query: string, pos: number, limit = 30): SuggestOutcome {
165+
export async function suggest(target: unknown, query: string, pos: number, limit = 30): Promise<SuggestOutcome> {
144166
try {
145167
const started = performance.now()
168+
const createQuery = await getCreateQuery()
146169
const statApi = createQuery(query, { tolerant: true, stat: true })(target) as {
147170
suggestion: (pos: number, opts?: { limit?: number }) => JoraStatEntry[] | null
148171
}

0 commit comments

Comments
 (0)