Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8b18530
Refactor /render route model loading
habdelra Oct 17, 2025
911b8ba
fixed typo in data attribute access
habdelra Oct 17, 2025
24bac12
cleanup
habdelra Oct 17, 2025
d9b0e5f
Support for `isUsed` in /render route
habdelra Oct 17, 2025
ed33ddb
Merge remote-tracking branch 'origin/main' into cs-9539-add-support-f…
habdelra Oct 20, 2025
a8dacdb
fix issue where glimmer throws "NotFoundError: Failed to execute 'rem…
habdelra Oct 20, 2025
c74f38d
better handling of NotLoadedValue sentinel during renders of instance…
habdelra Oct 20, 2025
01cff93
Strip last known good html from errors as this can confuse the DOM wh…
habdelra Oct 20, 2025
73c6cb2
relax timeout for indexing. prerendering can take much longer for rea…
habdelra Oct 20, 2025
74751c4
louder timeout log messages
habdelra Oct 20, 2025
b825384
Merge remote-tracking branch 'origin/main' into cs-9500-investigate-a…
habdelra Oct 20, 2025
c48c8ac
Added temp patch for glimmer-scoped-css until PR lands
habdelra Oct 21, 2025
cf57b0a
stripping out style stubs and more forgiving test waiting
habdelra Oct 21, 2025
e5819c4
remove debug code
habdelra Oct 21, 2025
7441a84
back out scoped-glimmer-approach
habdelra Oct 21, 2025
bda6215
retry render when we encounter removeChild error (symptomatic of glim…
habdelra Oct 21, 2025
447bd01
added comment to explain why we attempt a 2nd prerender for the remov…
habdelra Oct 21, 2025
f4ee460
Merge remote-tracking branch 'origin/main' into cs-9500-investigate-a…
habdelra Oct 21, 2025
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
44 changes: 39 additions & 5 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ export interface Field<
emptyValue(instance: BaseDef): any;
validate(instance: BaseDef, value: any): void;
component(model: Box<BaseDef>): BoxComponent;
getter(instance: BaseDef): BaseInstanceType<CardT>;
getter(instance: BaseDef): BaseInstanceType<CardT> | undefined;
queryableValue(value: any, stack: BaseDef[]): SearchT;
handleNotLoadedError(
instance: BaseInstanceType<CardT>,
Expand Down Expand Up @@ -426,13 +426,16 @@ class ContainsMany<FieldT extends FieldDefConstructor>
return this.cardThunk();
}

getter(instance: BaseDef): BaseInstanceType<FieldT> {
getter(instance: BaseDef): BaseInstanceType<FieldT> | undefined {
let deserialized = getDataBucket(instance);
entangleWithCardTracking(instance);
let maybeNotLoaded = deserialized.get(this.name);
// a not loaded error can blow up thru a computed containsMany field that consumes a link
if (isNotLoadedValue(maybeNotLoaded)) {
lazilyLoadLink(instance as CardDef, this, maybeNotLoaded.reference);
if ((globalThis as any).__lazilyLoadLinks) {
return this.emptyValue(instance) as BaseInstanceType<FieldT>;
}
}
return getter(instance, this);
}
Expand Down Expand Up @@ -736,13 +739,16 @@ class Contains<CardT extends FieldDefConstructor> implements Field<CardT, any> {
return this.cardThunk();
}

getter(instance: BaseDef): BaseInstanceType<CardT> {
getter(instance: BaseDef): BaseInstanceType<CardT> | undefined {
let deserialized = getDataBucket(instance);
entangleWithCardTracking(instance);
let maybeNotLoaded = deserialized.get(this.name);
// a not loaded error can blow up thru a computed contains field that consumes a link
if (isNotLoadedValue(maybeNotLoaded)) {
lazilyLoadLink(instance as CardDef, this, maybeNotLoaded.reference);
if ((globalThis as any).__lazilyLoadLinks) {
return undefined;
}
}
return getter(instance, this);
}
Expand Down Expand Up @@ -934,13 +940,16 @@ class LinksTo<CardT extends CardDefConstructor> implements Field<CardT> {
return this.cardThunk();
}

getter(instance: CardDef): BaseInstanceType<CardT> {
getter(instance: CardDef): BaseInstanceType<CardT> | undefined {
let deserialized = getDataBucket(instance);
// this establishes that our field should rerender when cardTracking for this card changes
entangleWithCardTracking(instance);
let maybeNotLoaded = deserialized.get(this.name);
if (isNotLoadedValue(maybeNotLoaded)) {
lazilyLoadLink(instance, this, maybeNotLoaded.reference);
if ((globalThis as any).__lazilyLoadLinks) {
return undefined;
}
}
return getter(instance, this);
}
Expand Down Expand Up @@ -1322,7 +1331,7 @@ class LinksToMany<FieldT extends CardDefConstructor>
value = this.emptyValue(instance);
deserialized.set(this.name, value);
lazilyLoadLink(instance, this, value.reference, { value });
return this.emptyValue as BaseInstanceType<FieldT>;
return this.emptyValue(instance) as BaseInstanceType<FieldT>;
}

// Ensure we have an array - if not, something went wrong during deserialization
Expand All @@ -1342,6 +1351,31 @@ class LinksToMany<FieldT extends CardDefConstructor>
}
}
if (notLoadedRefs.length > 0) {
// Important: we intentionally leave the NotLoadedValue sentinels inside the
// WatchedArray so the lazy loader can swap them out in place once the linked
// cards finish loading. Because the array identity never changes, Glimmer’s
// tracking sees the mutation and re-renders when lazilyLoadLink replaces each
// sentinel with a CardDef instance. Callers should treat these entries as
// placeholders (e.g. check for constructor.getComponent) rather than assuming
// every element is immediately renderable. Ideally the .value refactor can
// iron out this kink.
// TODO
// Codex has offered a couple interim solutions to ease the burden on card
// authors around this:
// We can wrap the guard in a reusable helper/component so card authors don’t
// have to think about the sentinel:
//
// - Helper – export something like `has-card-component` (just checks
// `value?.constructor?.getComponent`) from card-api. Then in templates
// they write: `{{#if (has-card-component card)}}…{{/if}}` or
// `{{#each (filter-loadable cards) as |c|}}`.
//
// - Component – provide a `LoadableCard` component that takes a card instance
// and renders the correct `CardContainer` only when the component is ready;
// otherwise it renders nothing or a skeleton. Card authors use
// `<LoadableCard @card={{card}}/>` instead of calling `getComponent`
// themselves.

if (!(globalThis as any).__lazilyLoadLinks) {
throw new NotLoaded(instance, notLoadedRefs, this.name);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { tracked } from '@glimmer/tracking';
import {
type CardContext,
type BaseDef,
type BoxComponent,
} from 'https://cardstack.com/base/card-api';

import { type PrerenderedCardLike } from '@cardstack/runtime-common';
Expand Down Expand Up @@ -41,12 +42,14 @@ export class CardWithHydration extends GlimmerComponent<CardWithHydrationSignatu
{{#if this.isHydrated}}
{{#if this.cardResource.card}}
{{#let (getComponent this.cardResource.card) as |Component|}}
<Component
class='card'
data-test-cards-grid-item={{removeFileExtension @card.url}}
data-cards-grid-item={{removeFileExtension @card.url}}
data-test-hydrated-card
/>
{{#if Component}}
<Component
class='card'
data-test-cards-grid-item={{removeFileExtension @card.url}}
data-cards-grid-item={{removeFileExtension @card.url}}
data-test-hydrated-card
/>
{{/if}}
{{/let}}
{{/if}}
{{else if @card.isError}}
Expand Down Expand Up @@ -101,10 +104,20 @@ export class CardWithHydration extends GlimmerComponent<CardWithHydrationSignatu
</template>
}

function getComponent(cardOrField: BaseDef) {
return cardOrField.constructor.getComponent(cardOrField);
function getComponent(cardOrField: BaseDef | undefined | null): BoxComponent | undefined {
if (!cardOrField) {
return;
}
let constructor = cardOrField.constructor as typeof BaseDef | undefined;
if (typeof constructor?.getComponent !== 'function') {
return;
}
return constructor.getComponent(cardOrField);
}

function removeFileExtension(cardUrl: string) {
return cardUrl?.replace(/\.[^/.]+$/, '');
function removeFileExtension(cardUrl: string | undefined | null) {
if (typeof cardUrl !== 'string') {
return '';
}
return cardUrl.replace(/\.[^/.]+$/, '');
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,18 @@ export class CardsIntancesGrid extends GlimmerComponent<CardsIntancesGridArgs> {
<ul class='cards {{this.view}}-view' ...attributes>
{{#each @cards key='url' as |card|}}
{{#let (getComponent card) as |CardComponent|}}
<li class='{{this.view}}-view-container'>
<CardContainer
class='card'
@displayBoundaries={{true}}
data-test-cards-grid-item={{removeFileExtension card.id}}
data-cards-grid-item={{removeFileExtension card.id}}
>
<CardComponent />
</CardContainer>
</li>
{{#if CardComponent}}
<li class='{{this.view}}-view-container'>
<CardContainer
class='card'
@displayBoundaries={{true}}
data-test-cards-grid-item={{removeFileExtension card.id}}
data-cards-grid-item={{removeFileExtension card.id}}
>
<CardComponent />
</CardContainer>
</li>
{{/if}}
{{/let}}
{{/each}}
</ul>
Expand Down Expand Up @@ -105,10 +107,19 @@ export class CardsIntancesGrid extends GlimmerComponent<CardsIntancesGridArgs> {
}

function getComponent(cardOrField: BaseDef) {
return cardOrField.constructor.getComponent(cardOrField);
if (
!cardOrField ||
typeof (cardOrField.constructor as { getComponent?: unknown })?.getComponent !== 'function'
) {
return;
}
return (cardOrField.constructor as typeof CardDef).getComponent(cardOrField);
}

function removeFileExtension(cardUrl: string) {
function removeFileExtension(cardUrl: string | undefined | null) {
if (typeof cardUrl !== 'string') {
return '';
}
return cardUrl.replace(/\.[^/.]+$/, '');
}

Expand Down
5 changes: 4 additions & 1 deletion packages/catalog-realm/catalog-app/listing/listing.gts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
StringField,
linksTo,
Component,
instanceOf,
realmURL,
} from 'https://cardstack.com/base/card-api';
import { commandData } from 'https://cardstack.com/base/resources/command-data';
Expand Down Expand Up @@ -579,7 +580,9 @@ export class SkillListing extends Listing {
function specBreakdown(specs: Spec[]): Record<string, Spec[]> {
return specs.reduce(
(groupedSpecs, spec) => {
if (!spec) {
if (!spec || !instanceOf(spec, Spec)) {
// During prerender linksToMany may still contain not-loaded placeholders;
// skip until the real Spec instance arrives.
return groupedSpecs;
}
let key = spec.specType ?? 'unknown';
Expand Down
12 changes: 1 addition & 11 deletions packages/host/app/components/card-prerender.gts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type PrerenderMeta,
type RenderRouteOptions,
serializeRenderRouteOptions,
cleanCapturedHTML,
} from '@cardstack/runtime-common';
import { readFileAsText as _readFileAsText } from '@cardstack/runtime-common/stream';
import {
Expand Down Expand Up @@ -497,17 +498,6 @@ function omitOneTimeOptions(options: RenderRouteOptions): RenderRouteOptions {
return options;
}

function cleanCapturedHTML(html: string): string {
if (!html) {
return html;
}
const emberIdAttr = /\s+id=(?:"ember\d+"|'ember\d+'|ember\d+)(?=[\s>])/g;
const emptyDataAttr = /\s+(data-[A-Za-z0-9:_-]+)=(?:""|''|(?=[\s>]))/g;
let cleaned = html.replace(emberIdAttr, '');
cleaned = cleaned.replace(emptyDataAttr, ' $1');
return cleaned;
}

function extractPrerenderError(markup: string): string | undefined {
if (!markup.includes('data-prerender-error')) {
return undefined;
Expand Down
4 changes: 2 additions & 2 deletions packages/host/app/lib/current-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
jobIdentity,
getFieldDefinitions,
modulesConsumedInMeta,
cleanCapturedHTML,
type ResolvedCodeRef,
type Definition,
type Batch,
Expand Down Expand Up @@ -1202,8 +1203,7 @@ export class CurrentRun {
}

function sanitizeHTML(html: string): string {
// currently this only involves removing auto-generated ember ID's
return html.replace(/\s+id="ember[0-9]+"/g, '');
return cleanCapturedHTML(html);
}

function assertURLEndsWithJSON(url: URL): URL {
Expand Down
45 changes: 37 additions & 8 deletions packages/host/app/routes/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ export default class RenderRoute extends Route<Model> {
currentURL: this.router.currentURL,
});
this.#setAllModelStatuses('unusable');
(globalThis as any)._lazilyLoadLinks = undefined;
(globalThis as any)._boxelRenderContext = undefined;
(globalThis as any).__lazilyLoadLinks = undefined;
(globalThis as any).__boxelRenderContext = undefined;
};

activate() {
Expand All @@ -98,8 +98,8 @@ export default class RenderRoute extends Route<Model> {
}

deactivate() {
(globalThis as any)._lazilyLoadLinks = undefined;
(globalThis as any)._boxelRenderContext = undefined;
(globalThis as any).__lazilyLoadLinks = undefined;
(globalThis as any).__boxelRenderContext = undefined;
(globalThis as any).__renderInstance = undefined;
window.removeEventListener('error', this.errorHandler);
window.removeEventListener('unhandledrejection', this.errorHandler);
Expand Down Expand Up @@ -455,10 +455,10 @@ export default class RenderRoute extends Route<Model> {
try {
let cardError: CardError = JSON.parse(error.message);
return JSON.stringify(
{
this.#stripLastKnownGoodHtml({
type: 'error',
error: cardError,
} as RenderError,
} as RenderError),
null,
2,
);
Expand All @@ -473,15 +473,44 @@ export default class RenderRoute extends Route<Model> {
} while (current && !id);
if (isCardError(error)) {
return JSON.stringify(
{ type: 'error', error: serializableError(error) },
this.#stripLastKnownGoodHtml({
type: 'error',
error: serializableError(error),
}),
null,
2,
);
}
let errorJSONAPI = formattedError(id, error).errors[0];
let errorPayload = errorJsonApiToErrorEntry(errorJSONAPI);
return JSON.stringify(errorPayload, null, 2);
return JSON.stringify(
this.#stripLastKnownGoodHtml(errorPayload),
null,
2,
);
}
}

#stripLastKnownGoodHtml<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) =>
this.#stripLastKnownGoodHtml(item),
) as unknown as T;
}
if (value && typeof value === 'object') {
let entries = Object.entries(value).reduce<Record<string, unknown>>(
(acc, [key, val]) => {
if (key === 'lastKnownGoodHtml') {
return acc;
}
acc[key] = this.#stripLastKnownGoodHtml(val);
return acc;
},
{},
);
return entries as T;
}
return value;
}

#deriveErrorContext(transition?: Transition): {
Expand Down
2 changes: 1 addition & 1 deletion packages/host/scripts/test-wait-for-servers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ TEST_REALM_READY="$TEST_REALM$READY_PATH"
SYNAPSE_URL="http://localhost:8008"
SMTP_4_DEV_URL="http://localhost:5001"

NODE_NO_WARNINGS=1 start-server-and-test \
WAIT_ON_TIMEOUT=1200000 NODE_NO_WARNINGS=1 start-server-and-test \
'pnpm run wait' \
"$BASE_REALM_READY|$CATALOG_REALM_READY|$NODE_TEST_REALM_READY|$SKILLS_REALM_READY|$TEST_REALM_READY|$SYNAPSE_URL|$SMTP_4_DEV_URL" \
'ember-test-pre-built'
2 changes: 1 addition & 1 deletion packages/matrix/scripts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ TEST_REALM_READY="$TEST_REALM$READY_PATH"

HOST_PATH="http://127.0.0.1:4200"

start-server-and-test \
WAIT_ON_TIMEOUT=600000 start-server-and-test \
'pnpm run wait' \
"$BASE_REALM_READY|$NODE_TEST_REALM_READY|$TEST_REALM_READY" \
"pnpm run start:host-pre-built" \
Expand Down
3 changes: 2 additions & 1 deletion packages/postgres/pg-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { PgAdapter } from './pg-adapter';
import * as Sentry from '@sentry/node';

const log = logger('queue');
const MAX_JOB_TIMEOUT_SEC = 10 * 60;

interface JobsTable {
id: number;
Expand Down Expand Up @@ -234,7 +235,7 @@ export class PgQueueRunner implements QueueRunner {
constructor({
adapter,
workerId,
maxTimeoutSec = 5 * 60,
maxTimeoutSec = MAX_JOB_TIMEOUT_SEC,
priority = 0,
}: {
adapter: PgAdapter;
Expand Down
3 changes: 3 additions & 0 deletions packages/realm-server/prerender/prerender-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ export function buildPrerenderApp(
pool,
},
};
if (pool.timedOut) {
log.warn(`render of ${url} timed out`);
}
if (response.error) {
log.debug(
`render of ${url} resulted in error doc:\n${JSON.stringify(response.error, null, 2)}`,
Expand Down
Loading
Loading