Skip to content
Open
148 changes: 116 additions & 32 deletions packages/host/app/components/operator-mode/card-error.gts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { cached } from '@glimmer/tracking';

import { restartableTask } from 'ember-concurrency';

import { CardHeader } from '@cardstack/boxel-ui/components';
import { CardHeader, LoadingIndicator } from '@cardstack/boxel-ui/components';
import type { MenuItem } from '@cardstack/boxel-ui/helpers';
import { FileAlert, ExclamationCircle } from '@cardstack/boxel-ui/icons';

Expand Down Expand Up @@ -42,50 +42,118 @@ export default class CardErrorComponent extends Component<Signature> {
<template>
{{#unless @hideHeader}}
<CardHeader
class='error-header'
@cardTypeDisplayName='Card Error: {{this.errorTitle}}'
@cardTypeIcon={{ExclamationCircle}}
{{! `error-header` is the structural hook consumers style (e.g. the
module inspector stretches it to full width), so it stays on in
both states; `pending` only restyles it. }}
class='error-header {{if this.isAwaitingIndex "pending"}}'
@cardTypeDisplayName={{this.headerDisplayName}}
@cardTypeIcon={{if
this.isAwaitingIndex
LoadingIndicator
ExclamationCircle
}}
@isTopCard={{@headerOptions.isTopCard}}
@moreOptionsMenuItems={{@headerOptions.moreOptionsMenuItems}}
@onClose={{@headerOptions.onClose}}
...attributes
/>
{{/unless}}

<div class='card-error' data-test-card-error={{this.id}}>
{{#if this.lastKnownGoodHtml}}
<this.lastKnownGoodHtml />
{{else}}
<div class='card-error-default'>
<FileAlert class='icon' />
<div class='message'>
{{#if @message}}
{{@message}}
{{else if @cardCreationError}}
Failed to create card.
{{else}}
This card contains an error.
{{/if}}
</div>
{{#if this.isAwaitingIndex}}
{{! A live region: the placeholder promises the card will appear on its
own, so its arrival has to be announced rather than only drawn. }}
<div
class='card-pending'
role='status'
aria-live='polite'
data-test-card-awaiting-index={{this.id}}
>
<LoadingIndicator class='pending-icon' />
<div class='pending-message'>
<p class='pending-headline'>Preparing this card</p>
<p class='pending-detail'>
The workspace has the file and is still getting it ready.
</p>
</div>
{{/if}}
</div>
<CardErrorDetail
@error={{@error}}
@title={{this.errorTitle}}
@viewInCodeMode={{@viewInCodeMode}}
@fileToFixWithAi={{@fileToFixWithAi}}
class='card-error-detail'
>
<:error>
{{yield to='error'}}
</:error>
</CardErrorDetail>
</div>
{{else}}
<div class='card-error' data-test-card-error={{this.id}}>
{{#if this.lastKnownGoodHtml}}
<this.lastKnownGoodHtml />
{{else}}
<div class='card-error-default'>
<FileAlert class='icon' />
<div class='message'>
{{#if @message}}
{{@message}}
{{else if @cardCreationError}}
Failed to create card.
{{else}}
This card contains an error.
{{/if}}
</div>
</div>
{{/if}}
</div>
<CardErrorDetail
@error={{@error}}
@title={{this.errorTitle}}
@viewInCodeMode={{@viewInCodeMode}}
@fileToFixWithAi={{@fileToFixWithAi}}
class='card-error-detail'
>
<:error>
{{yield to='error'}}
</:error>
</CardErrorDetail>
{{/if}}
<style scoped>
.icon {
height: 100px;
width: 100px;
}
.card-pending {
display: flex;
flex: 1;
height: 100%;
align-content: center;
justify-content: center;
flex-wrap: wrap;
gap: var(--boxel-sp-xs);
padding: var(--boxel-sp);
}
.pending-icon {
--boxel-loading-indicator-size: 60px;
color: var(--boxel-400);
}
.pending-message {
width: 100%;
text-align: center;
text-wrap: pretty;
}
.pending-headline {
margin: 0;
font: 600 var(--boxel-font);
}
.pending-detail {
margin: var(--boxel-sp-xxs) auto 0;
max-width: 40ch;
color: var(--boxel-450);
font: var(--boxel-font-sm);
}
.error-header.pending {
min-height: var(--boxel-form-control-height);
background-color: var(--boxel-100);
box-shadow: 0 1px 0 0 rgba(0 0 0 / 15%);
}
/* The consumer sets --boxel-card-header-text-color from the realm's own
colour — white for a dark realm — which says nothing about the grey
painted above. Name a colour that belongs to this background, the way
the error state names its own. */
.error-header.pending :deep(.card-type-display-name),
.error-header.pending :deep(.boxel-loading-indicator) {
color: var(--boxel-dark);
}
.card-error-default {
display: flex;
height: 100%;
Expand Down Expand Up @@ -143,6 +211,22 @@ export default class CardErrorComponent extends Component<Signature> {
return this.args.error.id;
}

// The realm answers a card+json read for an instance whose source it holds
// but has not indexed yet with a 404 carrying this marker. Nothing is wrong
// with the card — the indexing pass the write kicked off just hasn't landed —
// and the store reloads the instance when the realm broadcasts the index
// event for it, so this stands in until the real card takes over rather than
// reporting a card that isn't there.
private get isAwaitingIndex() {
return this.args.error.awaitingIndex === true;
}

private get headerDisplayName() {
return this.isAwaitingIndex
? 'Preparing Card'
: `Card Error: ${this.errorTitle}`;
}

private get errorTitle() {
if (this.args.title) {
return this.args.title;
Expand Down
11 changes: 11 additions & 0 deletions packages/host/app/lib/gc-card-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,17 @@ export default class CardStoreWithGarbageCollection implements CardStore {
return this.getCardItem('error', id);
}

// Every card error currently held, as [store key, error] pairs. Reading it
// inside an autotracked computation re-runs when an error is added or
// removed. The non-tracked bucket is included so a caller sweeping errors
// sees the same set `getCardError` would return for each key.
cardErrorEntries(): [string, CardErrorJSONAPI][] {
return [
...this.#nonTrackedCardInstanceErrors.entries(),
...this.#cardInstanceErrors.entries(),
];
}

getFileMetaError(id: string) {
return this.getFileMetaItem('error', id);
}
Expand Down
110 changes: 104 additions & 6 deletions packages/host/app/services/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2019,15 +2019,21 @@ export default class StoreService extends Service implements StoreInterface {
};

if (event.indexType === 'full') {
// A full reindex carries no per-file invalidation list; report it as a
// thin realm-event so the dashboard still sees the pass happened.
// A full reindex carries no per-file invalidation list, so there is
// nothing to reload by name. A realm that reindexes on request does
// broadcast the URLs it visited as an incremental event first, but one
// reindexing at startup announces itself with this event alone — so this
// can be the only word a card being held as awaiting-index ever gets
// that the row it is waiting for now exists.
let reloadsTriggered = this.reloadAwaitingIndexInstances(event.realmURL);
// Report the pass as a thin realm-event so the dashboard still sees it.
telemetry?.recordEvent({
event_type: 'realm-event',
realm: event.realmURL,
index_type: 'full',
invalidations_count: 0,
invalidated_ids: [],
reloads_triggered: 0,
reloads_triggered: reloadsTriggered,
own_write: false,
processing_ms: 0,
event_args: eventArgs(),
Expand Down Expand Up @@ -2203,6 +2209,20 @@ export default class StoreService extends Service implements StoreInterface {
this.loadInstanceTask.perform(invalidation);
reloadsTriggered++;
}
} else if (this.hasInflightCardLoad(invalidation)) {
// The invalidation landed while this id's first read was still in
// flight, so there is nothing in the store to reload yet. That read
// may well be the one that 404s — the index row this event announces
// did not exist when it was issued — and its awaiting-index
// placeholder would then be stale the moment it is installed, with no
// further event coming for it. Reload once the read settles.
// Deliberately not counted as a reload: whether one happens depends on
// what the read settles into, and the counter is read synchronously
// here for the realm-event telemetry.
realmEventsLogger.debug(
`deferring reload of ${invalidation} until its in-flight load settles`,
);
this.reloadAfterInflightLoad.perform(invalidation);
} else {
realmEventsLogger.debug(
`ignoring invalidation ${invalidation} because we did not previously try to load it`,
Expand Down Expand Up @@ -2256,6 +2276,50 @@ export default class StoreService extends Service implements StoreInterface {
},
);

// Is a first read of `id` still in flight? `inflightGetCards` is keyed by the
// normalized URL, which is the form an invalidation carries.
private hasInflightCardLoad(id: string): boolean {
let url = asURL(id, this.network.virtualNetwork);
return url ? this.inflightGetCards.has(url) : false;
}

// Wait out the in-flight read of `id`, then reload it if what it produced was
// an awaiting-index placeholder. That placeholder is the store's promise that
// the card will appear on its own, and the event that would have kept the
// promise is the one already being handled — it arrived too early to find
// anything to reload.
private reloadAfterInflightLoad = task(async (id: string) => {
let url = asURL(id, this.network.virtualNetwork);
let inflight = url ? this.inflightGetCards.get(url) : undefined;
if (inflight) {
await inflight;
}
if (this.peekError(id)?.awaitingIndex) {
this.loadInstanceTask.perform(id);
}
});

// Re-read every card being held as awaiting-index in `realmURL`. Their whole
// state is "a row for me is coming", and a from-scratch pass is one way it
// arrives without any event naming the card.
private reloadAwaitingIndexInstances(realmURL: string): number {
let reloaded = 0;
for (let [id, error] of this.store.cardErrorEntries()) {
if (!error.awaitingIndex) {
continue;
}
if (this.realm.realmOf(rri(id)) !== realmURL) {
continue;
}
realmEventsLogger.debug(
`reloading ${id} because a full index of ${realmURL} may have landed the row it is waiting for`,
);
this.loadInstanceTask.perform(id);
reloaded++;
}
return reloaded;
}

private reestablishReferences = task(async () => {
let remoteIds = new Set<string>();
for (let [id, referenceCount] of this.referenceCount) {
Expand Down Expand Up @@ -2381,13 +2445,22 @@ export default class StoreService extends Service implements StoreInterface {
try {
maybeReloadedInstance = await this.reloadInstance(instance);
} catch (err: any) {
if (err.status === 404) {
let cardError = processCardError(instance.id, err).errors[0];
if (cardError?.awaitingIndex) {
// The realm holds this card's source and has not indexed it yet.
// That is a statement about the index, not about the instance this
// tab is already running — so keep it exactly as it is, autosave and
// all, and let the index event that follows bring the fresh state.
// Treating it as a deletion would evict a card that still exists;
// recording it as an error would stand a placeholder in front of one
// the user is working in.
maybeReloadedInstance = instance;
} else if (err.status === 404) {
// in this case the document was invalidated in the index because the
// file was deleted
isDelete = true;
} else {
let errorResponse = processCardError(instance.id, err);
maybeReloadedInstance = errorResponse.errors[0];
maybeReloadedInstance = cardError;
}
}
// Detach the original instance's autosave subscription when it's been
Expand Down Expand Up @@ -2480,6 +2553,22 @@ export default class StoreService extends Service implements StoreInterface {
if (!instance && !instanceOrError.id) {
return;
}
// An awaiting-index error says the realm has not caught up with a card it
// holds. It is never a statement about a card this tab is already running:
// a newly created instance is live in the store under its local id, and
// editable there, long before the realm has indexed it. Recording the error
// would make `peekError` report it, and every render site reads that to
// decide whether to stand a placeholder in front of the card — so a card
// the user is working in would be replaced by one. `getCard` correlates a
// remote URL back to a locally-created instance, so this holds from the
// moment the server assigns an id.
if (
!instance &&
(instanceOrError as CardErrorJSONAPI).awaitingIndex &&
this.store.getCard(instanceOrError.id!)
) {
return;
}
this.store.addCardInstanceOrError(
instance ? (instance.id ?? instance[localIdSymbol]) : instanceOrError.id!, // we checked above to make sure errors have id's
instanceOrError as CardDef | CardErrorJSONAPI,
Expand Down Expand Up @@ -2764,6 +2853,15 @@ export default class StoreService extends Service implements StoreInterface {
} catch (error: any) {
let errorResponse = processCardError(id, error);
let cardError = errorResponse.errors[0];
// A card this tab is already running outranks the realm's report that it
// has not indexed it yet — see `setIdentityContext`. A cache-bypassing
// read is the one that gets here with an instance already in hand.
let running =
cardError?.awaitingIndex && id ? this.store.getCard(id) : undefined;
if (running) {
deferred?.fulfill(running as T);
return running as T;
}
deferred?.fulfill(cardError);
this.setIdentityContext(cardError);
let status = cardError?.status ?? error?.status;
Expand Down
Loading
Loading