Skip to content
Open
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
15 changes: 12 additions & 3 deletions docs/boxel-rendering-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -658,9 +658,10 @@ isolated render) plus its scoped-CSS URLs.
`packages/runtime-common/boxel-execution-protocol.ts`: cloneable, versioned,
no Ember imports. Records: `CodeRef`; `BoxelDescription` (ref, kind,
ancestors, fields, formats, presentation statics); `FieldDescription`
(`fieldName`, `type` code ref, `kind`, `isComputed`) — configuration is
**not** on the type description, because resolution takes the owning root
instance as `this` and memoizes per `(instance, fieldName)` (RP-5.1–5.2);
(`fieldName`, `type` code ref, `kind`, `isComputed`, `isQueryBacked`) —
configuration is **not** on the type description, because resolution takes
the owning root instance as `this` and memoizes per `(instance, fieldName)`
(RP-5.1–5.2);
`ResolvedField` (a field's declaration plus the configuration resolved
against one instance), which is what `getFields`/`getField` answer with and
which carries no value, since the value lives in the projection's model;
Expand Down Expand Up @@ -690,6 +691,14 @@ linking a Theme and another for a Theme card previewing its own CSS, and
the second links no Theme at all — so neither the theme reference nor
`themeCss` implies it.

`isComputed` and `isQueryBacked` are carried separately rather than reduced
to a single writability flag, because render-time writability is
`(not computeVia) ∧ (not queryDefinition) ∧ permissions.canWrite` (RP-9.1)
and the permissions term is context the Host pushes per mounted surface, not
a fact about the field — a record answering "writable" would have to guess
it. A record carrying only `isComputed` cannot state the rule at all: a
query-backed relationship is never editable (RP-7.6) and is not computed.

`trusted-export` is a single portal token — module plus export name — and
not a per-category split. Whether that export is admissible as a component,
a helper, or a modifier is decided where the token is redeemed, against the
Expand Down
12 changes: 12 additions & 0 deletions packages/base/card-api.gts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import {
type BoxComponent,
CardCrudFunctionsConsumer,
DefaultFormatsConsumer,
getCssImports,
hasTheme,
isThemeCard,
themeCss,
themeId,
} from './field-component';
import { getContainsManyComponent } from './contains-many-component';
import { LinksToEditor } from './links-to-editor';
Expand Down Expand Up @@ -179,6 +184,7 @@ import {
getter,
registerRelationshipProbe,
relationshipStateForEntry,
resolveFieldConfiguration,
readFieldLoadingSignal,
bumpFieldLoadingSignal,
isArrayOfCardOrField,
Expand Down Expand Up @@ -241,6 +247,12 @@ export {
primitive,
realmURL,
relativeTo,
resolveFieldConfiguration,
isThemeCard,
themeCss,
hasTheme,
themeId,
getCssImports,
serialize,
serializeCard,
serializeFileDef,
Expand Down
86 changes: 49 additions & 37 deletions packages/base/field-component.gts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,55 @@ const componentCache = initSharedState(
>(),
);

// The theme derivation a themed card's CardContainer invocation is built from.
// Module-scope and exported rather than private to `getBoxComponent`, because
// a card's theme has to be derivable outside a live render: an execution tier
// that renders from projected data reads these answers as data, and a second
// implementation of them would drift from this one with nothing to go red.
//
// A card is a Theme when it declares its own `cssVariables` through a
// `CSSField`; every other card takes its theme from the `cardTheme` mirror of
// `cardInfo.theme`.
export function isThemeCard(cardDef?: CardDef): cardDef is Theme {
if (cardDef && 'cssVariables' in cardDef) {
let field = getField(cardDef, 'cssVariables');
return field?.card?.name === 'CSSField';
}
return false;
}

export function themeCss(cardDef?: CardDef) {
return isThemeCard(cardDef)
? cardDef.cssVariables
: cardDef?.cardTheme?.cssVariables;
}

// Answered two ways on purpose: an ordinary card is themed when it links a
// Theme, and a Theme card previewing its own CSS is themed when that CSS is
// non-empty — and such a card links no Theme at all.
export function hasTheme(cardDef?: CardDef) {
if (isThemeCard(cardDef)) {
return Boolean(cardDef?.cssVariables?.trim());
}
return cardDef?.cardTheme != null;
}

export function themeId(cardDef?: CardDef) {
return isThemeCard(cardDef) ? cardDef.id : cardDef?.cardTheme?.id;
}

export function getCssImports(card?: CardDef): string[] | undefined {
// for cards like Theme card and its descendants, directly use the `cssImports` field;
// for all other cards, get imports via the Theme card linked from cardInfo
if (card && 'cssImports' in card) {
let field = getField(card, 'cssImports');
if (field?.card?.name === 'CssImportField') {
return card.cssImports as string[] | undefined;
}
}
return card?.cardTheme?.cssImports;
}

export function getBoxComponent(
cardOrField: typeof BaseDef,
model: Box<BaseDef>,
Expand Down Expand Up @@ -259,43 +308,6 @@ export function getBoxComponent(
};
}

function isThemeCard(cardDef?: CardDef): cardDef is Theme {
if (cardDef && 'cssVariables' in cardDef) {
let field = getField(cardDef, 'cssVariables');
return field?.card?.name === 'CSSField';
}
return false;
}

function themeCss(cardDef?: CardDef) {
return isThemeCard(cardDef)
? cardDef.cssVariables
: cardDef?.cardTheme?.cssVariables;
}

function hasTheme(cardDef?: CardDef) {
if (isThemeCard(cardDef)) {
return Boolean(cardDef?.cssVariables?.trim());
}
return cardDef?.cardTheme != null;
}

function themeId(cardDef?: CardDef) {
return isThemeCard(cardDef) ? cardDef.id : cardDef?.cardTheme?.id;
}

function getCssImports(card?: CardDef): string[] | undefined {
// for cards like Theme card and its descendants, directly use the `cssImports` field;
// for all other cards, get imports via the Theme card linked from cardInfo
if (card && 'cssImports' in card) {
let field = getField(card, 'cssImports');
if (field?.card?.name === 'CssImportField') {
return card.cssImports as string[] | undefined;
}
}
return card?.cardTheme?.cssImports;
}

let component = class FieldComponent extends Component<BoxComponentSignature> {
// Scopes this card's theme stylesheet. Derived from the theme card's id
// plus a hash of its CSS (see themeScope) so every card sharing a theme
Expand Down
174 changes: 174 additions & 0 deletions packages/host/app/lib/boxel-projection-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import type { BoxelExecutionMode } from '@cardstack/runtime-common/boxel-execution-protocol';
import type { CodeRef } from '@cardstack/runtime-common/code-ref';

import config from '@cardstack/host/config/environment';

/**
* Where a consumer read a path the projection does not carry.
*
* The four members are what it takes to act on one. The path alone says a
* member is missing; the type says whose projection was supposed to carry it,
* the format says which template asked, and the mode says which tier produced
* the projection — which is the difference between "the pipeline does not
* project this" and "this tier's adapter dropped it".
*/
export interface MissingProjectionPath {
path: string;
type: CodeRef;
format: string;
mode: BoxelExecutionMode;
}

export type MissingProjectionPathReporter = (
missing: MissingProjectionPath,
) => void;

export interface MissingProjectionPathContext {
type: CodeRef;
format: string;
mode: BoxelExecutionMode;
/** Where the reported path starts, e.g. `model`. Defaults to `model`. */
root?: string;
/** Defaults to one `console.warn` per distinct path. */
report?: MissingProjectionPathReporter;
}

/**
* Watches reads of a projection and reports the paths it does not carry.
*
* A projection is data, so a member the pipeline failed to project is not an
* error anywhere — it reads as `undefined`, the binding renders empty, and the
* card is subtly wrong with nothing in any log. That silence is the specific
* problem here: the record is built by one pipeline and read by templates
* nobody enumerated, so the only way to learn which members a real card
* actually wants is to watch a real card read them.
*
* Two properties make this safe to leave in the code:
*
* - **It never synthesizes.** A missing member reads as `undefined` through
* this wrapper exactly as it does without it. Nothing here can make a card
* render differently, so a report is evidence about the projection rather
* than a change to it.
* - **It never ships.** In a production build this is the identity function,
* returning the record itself — no proxy is created, no read is intercepted,
* and the reporter is unreachable. Diagnostics that observe every property
* read belong to the loop that finds the gaps, not to the app.
*
* What comes back is a read-only observer view of the record, not the record.
* Hand the original to anything that stores, clones, or sends it; this one is
* for reading through. A view does not survive `structuredClone` — a Proxy
* never does — which is the same rule stated from the other side.
*/
export function observeMissingProjectionPaths<T>(
record: T,
context: MissingProjectionPathContext,
): T {
if (config.environment === 'production') {
return record;
}
let report = context.report ?? warnOnce();
return watch(record, context.root ?? 'model', context, report);
}

/**
* String keys that are a probe of the value rather than a path through the
* card.
*
* `then` is how anything awaited is tested for thenability. `toJSON` is what
* `JSON.stringify` reaches for on every object it walks — and unlike
* `toString` or `valueOf` it is not on `Object.prototype`, so a presence test
* reports it missing. Serializing a watched projection while debugging is the
* likeliest thing anyone does with one, and left unhandled it emits a bogus
* report per object in the graph, burying the real gaps.
*/
const VALUE_PROBES = new Set(['then', 'toJSON']);

function watch<T>(
value: T,
path: string,
context: MissingProjectionPathContext,
report: MissingProjectionPathReporter,
// One wrapper per underlying object, so two reads of a member answer with
// the same view. A fresh Proxy per read makes `a.b !== a.b`, which re-keys
// an `{{#each}}` on every re-render and makes an identity comparison of one
// member against itself false — in a value whose intended consumer is a
// template.
wrappers = new WeakMap<object, unknown>(),
): T {
if (typeof value !== 'object' || value === null) {
return value;
}
let existing = wrappers.get(value as object);
if (existing !== undefined) {
return existing as T;
}
let watched = new Proxy(value as object, {
get(target, key, receiver) {
// Symbols are the language's own protocol — iteration, primitive
// coercion, `instanceof` — and never a projected path.
if (typeof key !== 'string' || VALUE_PROBES.has(key)) {
return Reflect.get(target, key, receiver);
}
let reached = `${path}.${key}`;
if (!(key in target)) {
report({
path: reached,
type: context.type,
format: context.format,
mode: context.mode,
});
return undefined;
}
let value = Reflect.get(target, key, receiver);
// A proxy must hand back the exact value of a non-writable,
// non-configurable own member, so wrapping one is a `TypeError` rather
// than a diagnostic. Records this pipeline builds have neither, but a
// consumer is free to freeze what it was given, and a diagnostic that
// throws on frozen input is worse than one that stops watching below it.
let own = Reflect.getOwnPropertyDescriptor(target, key);
if (own && !own.configurable && !own.writable) {
return value;
}
return watch(value, reached, context, report, wrappers);
},
});
wrappers.set(value as object, watched);
return watched as T;
}

/**
* One warning per distinct path.
*
* A missing member is read on every re-render, and a template inside an
* `{{#each}}` reads it once per row, so an un-deduplicated reporter buries the
* second distinct gap under a thousand copies of the first.
*/
function warnOnce(): MissingProjectionPathReporter {
let seen = new Set<string>();
return ({ path, type, format, mode }) => {
// Element positions are collapsed for the purpose of deciding what is new,
// because a row's index is not what makes a gap distinct: a grid of a
// thousand rows reads the same missing member a thousand times, at a
// thousand paths. The warning still names the concrete path that reached
// it, so the reader gets a member they can go and look at rather than a
// pattern they have to instantiate.
let key = `${mode}/${format}/${path.replace(/\.\d+(?=\.|$)/g, '.#')}`;
if (seen.has(key)) {
return;
}
seen.add(key);
console.warn(
`Boxel projection has no '${path}' — read while rendering ` +
`${describeRef(type)} as '${format}' in ${mode} execution`,
);
};
}

function describeRef(ref: CodeRef): string {
if ('type' in ref) {
return ref.type === 'ancestorOf'
? `the ancestor of ${describeRef(ref.card)}`
: `the '${ref.field}' field of ${describeRef(ref.card)}`;
}
return `${ref.name} from ${ref.module}`;
}
Loading
Loading