diff --git a/packages/host/app/components/markdown-embed-chooser/pane.gts b/packages/host/app/components/markdown-embed-chooser/pane.gts index e0baf9c1571..26937ad7e09 100644 --- a/packages/host/app/components/markdown-embed-chooser/pane.gts +++ b/packages/host/app/components/markdown-embed-chooser/pane.gts @@ -59,6 +59,10 @@ interface Signature { // Overrides the dynamic "Insert as …" CTA label. Used in edit mode to // show 'Done' (clean) or 'Accept' (dirty) per the design spec. ctaLabelOverride?: string; + // True while the picked target is still resolving. The viewport renders a + // size-matched loading placeholder and the CTA is held disabled until the + // instance (or an error) lands. + loading?: boolean; }; } @@ -124,6 +128,9 @@ export default class MarkdownEmbedPreviewPane extends Component { // for editing seeds the Custom category with no dimensions but is a valid, // supported form, so its unchanged (non-dirty) state keeps the CTA enabled. private get ctaDisabled(): boolean { + if (this.args.loading) { + return true; + } return !this.args.selection.hasValidSize && this.args.selection.isDirty; } @@ -170,6 +177,7 @@ export default class MarkdownEmbedPreviewPane extends Component {
; @@ -52,33 +58,45 @@ const Embed: TOC = ; @@ -194,6 +230,10 @@ interface Signature { // nothing. Absent when the ref failed to resolve; the broken-ref args // below then drive the render. target?: CardDef | FileDef; + // True while the caller is still resolving the target. Renders a + // size-matched loading placeholder (the card's footprint with a spinner) + // in the embed's slot until `target` or a broken-ref state arrives. + loading?: boolean; // Broken-ref render: when `brokenUrl` is present (and `target` is not), // render `BrokenLinkTemplate` instead of the embed. The same warning box + // reveal overlay the base `linksTo` broken UI shows, format-aware so the @@ -327,6 +367,13 @@ export default class MarkdownEmbedPreview extends Component { @kind={{this.kind}} @sizeStyle={{this.sizeStyle}} /> + {{else if @loading}} + {{else if @brokenUrl}} { @sizeStyle={{this.sizeStyle}} ...attributes /> + {{else if @loading}} + {{else if @brokenUrl}} { this.selectedUrl = url; this.selectedTarget = undefined; this.selectedError = undefined; - let result = - refType === 'card' - ? await this.store.get(url) - : await this.store.get(url, { type: 'file-meta' }); - if (isCardErrorJSONAPI(result)) { - // Keep `selectedUrl` and leave `selectedTarget` undefined; the pane - // renders the broken-ref visual from `selectedError` instead of the - // resolved embed. - this.selectedError = result; - return; + try { + let result = + refType === 'card' + ? await this.store.get(url) + : await this.store.get(url, { type: 'file-meta' }); + if (isCardErrorJSONAPI(result)) { + // Keep `selectedUrl` and leave `selectedTarget` undefined; the pane + // renders the broken-ref visual from `selectedError` instead of the + // resolved embed. + this.selectedError = result; + return; + } + this.selectedTarget = result as CardDef | FileDef; + } catch (e) { + // A superseding pick cancels this run; let that bubble so the newer + // load owns the state. `store.get` resolves its own errors as + // `CardErrorJSONAPI` (handled above), so reaching here means an + // unexpected throw — surface it as a broken ref rather than leaving + // the pane spinning on its loading state forever. + if (didCancel(e)) { + throw e; + } + this.selectedError = { + id: url, + status: 500, + title: 'Failed to load', + message: e instanceof Error ? e.message : String(e), + } as CardErrorJSONAPI; } - this.selectedTarget = result as CardDef | FileDef; }, ); // The pane mounts once a row is picked and either resolves (selectedTarget) - // or fails (selectedError); the empty placeholder shows only before then. + // or fails (selectedError). Before then the right column shows the loading + // indicator while `isLoading`, falling back to the empty placeholder only + // when nothing is being resolved. private get hasPreview(): boolean { return !!this.selectedTarget || !!this.selectedError; } + // A pick is resolving: its URL is set but neither the instance nor an error + // has landed yet. Drives the loading placeholder so a slow (cold) load reads + // as "loading", not as the "nothing selected" placeholder. + private get isLoading(): boolean { + return this.loadTarget.isRunning; + } + + // The pane mounts while a pick is loading, resolved, or errored — so the + // format/size controls and the size-matched preview stay put across the + // load (no jump to a bare spinner). The empty placeholder shows only before + // anything is picked. + private get showPane(): boolean { + return this.hasPreview || this.isLoading; + } + // Broken-ref state threaded to the pane. Each is undefined unless the load // failed, so the pane renders the resolved embed on the happy path and the // broken visual only when `selectedError` is set. @@ -317,9 +351,10 @@ export default class MarkdownEmbedChooserTabPanel extends Component { {{/if}}
- {{#if this.hasPreview}} + {{#if this.showPane}} void; + let gate = new Promise((resolve) => (releaseLoad = resolve)); + store.get = ((id: string, opts?: unknown) => { + if (id === mango) { + return gate.then(() => origGet(id, opts as never)); + } + return origGet(id, opts as never); + }) as typeof store.get; + + await render( + , + ); + + let svc = getService( + 'markdown-embed-chooser', + ) as MarkdownEmbedChooserService; + let pending = svc.chooseCardOrFile({ defaultTab: 'card' }); + await waitFor('[data-test-markdown-embed-chooser-modal]'); + + await fillIn( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-search-field]', + 'Mango', + ); + await waitFor( + `[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-item-button="${mango}"]`, + { timeout: 5000 }, + ); + + // Fire the click natively rather than via `click()` (which awaits + // settledness and would block on the gated load task) so we can assert the + // in-flight loading state. + ( + document.querySelector( + `[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-item-button="${mango}"]`, + ) as HTMLElement + ).click(); + + await waitFor( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview-loading]', + { timeout: 5000 }, + ); + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview-loading]', + ) + .exists( + 'the preview pane shows a loading indicator while the pick loads', + ); + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview-empty]', + ) + .doesNotExist('the empty placeholder is suppressed during loading'); + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview-pane]', + ) + .exists( + 'the preview pane (with its format controls) stays mounted while loading', + ); + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview]', + ) + .doesNotExist('the resolved embed is not shown until the load completes'); + + releaseLoad(); + await settled(); + + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview]', + ) + .exists('the resolved card renders its embed once the load completes'); + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview-loading]', + ) + .doesNotExist('the loading indicator clears once the preview is shown'); + + svc.resolve(undefined); + await pending; + }); + + test('a card load that throws shows the broken preview, not a stuck loading/empty state', async function (assert) { + let store = getService('store') as StoreService; + let origGet = store.get.bind(store); + store.get = ((id: string, opts?: unknown) => { + if (id === mango) { + return Promise.reject(new Error('boom')); + } + return origGet(id, opts as never); + }) as typeof store.get; + + await render( + , + ); + + let svc = getService( + 'markdown-embed-chooser', + ) as MarkdownEmbedChooserService; + let pending = svc.chooseCardOrFile({ defaultTab: 'card' }); + await waitFor('[data-test-markdown-embed-chooser-modal]'); + + await fillIn( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-search-field]', + 'Mango', + ); + await waitFor( + `[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-item-button="${mango}"]`, + { timeout: 5000 }, + ); + await click( + `[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-item-button="${mango}"]`, + ); + + await waitFor( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-broken-link-template]', + { timeout: 5000 }, + ); + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-broken-link-template]', + ) + .exists('a thrown load surfaces the broken-ref visual'); + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview-empty]', + ) + .doesNotExist('the empty placeholder is suppressed for a failed load'); + assert + .dom( + '[data-test-markdown-embed-chooser-tab-panel="card"] [data-test-markdown-embed-preview-loading]', + ) + .doesNotExist('the pane does not stay stuck on the loading indicator'); + + svc.resolve(undefined); + await pending; + }); + test('a picked card serializes a document-relative ref when a base URL is supplied', async function (assert) { await render(