Error recovery
A model sometimes emits a broken expression. Containment keeps the failure
inside its element. Recovery swaps in a corrected element by re-emitting its
key (partial replacement), which clears the error and retries
the render. The three ways to recover differ in who notices the failure and who
asks for the fix.
Containment: the error slot
Every element renders inside its own error boundary. If its props, hidden,
loading, each or seed throws, or its implementation does, only that element is
replaced by the error slot.
A corrected re-emit retries a failed seed, filling only paths still unset
(first-writer-wins). A seed that succeeded never
re-runs.
Classification: EntryError and onError
Re-emitting helps only when the model made the mistake, not when your server
returned a 500. So every failure is an EntryError from @uicast/core,
classified by whose code raised it:
class EntryError extends Error {
reason: EntryErrorReason; // the specific verdict — see the table
fault: "document" | "environment" | "unknown"; // derived from reason
elementKey?: string; // the element the failure belongs to
cause?: unknown; // the original error
static is(err: unknown): err is EntryError; // cross-copy-safe check — use over `instanceof`
}reason | Meaning | fault |
|---|---|---|
expression-syntax | the expression doesn’t parse | document |
guardrail-violation | the expression uses blocked syntax or APIs, or a set is not scopes.<scope>.<field> | document |
budget-exceeded | the expression ran past its step, time or allocation budget | document |
unknown-reference | an unregistered function, global, or scope path | document |
invalid-entry | a field has the wrong type, like a seed that is not an array | document |
unknown-component | the component name has no implementation | document |
invalid-list | each didn’t evaluate to an array, or as names a scope that already exists | document |
invalid-props | the evaluated props fail the def’s schema, before render runs | document |
invalid-arguments | a host function rejected the document’s arguments | document |
host-function | a host function threw or rejected | environment |
host-init | the host init callback threw or rejected | environment |
implementation | the render threw on props the def’s schema accepts | environment |
expression-runtime | a valid expression threw at runtime (a read through null) | unknown |
unknown | escaped from an uninstrumented path | unknown |
fault is the verdict: document means the model wrote the bug and a
re-emission can fix it; environment means host code failed; unknown could be
either.
The same object reaches the error slot and the provider’s onError,
called once per failure, including callback failures, which render no slot:
<RendererProvider
evaluator={evaluator}
implementations={implementations}
onError={(error) => {
if (error.fault === "document") {
// the model can fix this — feed it back (see self-healing below)
} else {
reportToMonitoring(error);
}
}}
>
<EntriesRenderer entries={entries} />
</RendererProvider>1. Self-correction
The model re-emits a broken element later in the same output:
{"key":"card","component":"Card","props":{"expr":"({ title: window.title })"},"children":["kpi"]}
{"key":"kpi","component":"Stat","props":{"literal":{"label":"Products","value":128}}}
{"key":"card","component":"Card","props":{"literal":{"title":"Inventory"}},"children":["kpi"]}Line 1 fails with unknown-reference. Line 3 fixes it: same key, and
children still names kpi, so that child survives untouched.
The prompt tells the model to correct a mistake once, not to retry repeatedly. A replacement line also works in a later turn.
2. User-triggered recovery
The model never sees run-time failures, such as an expression that throws on
real data or a host function that rejects. Here the host’s error slot offers a
Recover button that sends the failure back as a new turn, formatted by
getErrorRecoveryPrompt:
import { getErrorRecoveryPrompt } from "@uicast/core/prompt";
getErrorRecoveryPrompt({
failures: [
{
key: "chart",
message: "chartData.map is not a function",
reason: error.reason, // optional
},
],
});returns:
Rendered UI has errors:
- Entry `chart`: chartData.map is not a function (expression-runtime — valid expression threw at runtime)
Re-emit each failed entry fixed, same `key`. Fix cause: when it comes from another entry (like `seed` elsewhere), fix that entry too.reason adds the failure class to the line. An environment fault gets none,
as it is not the model’s to fix.
The Recover button is host UI, since only the host knows how to reach its model:
import type { ErrorComponentProps } from "@uicast/react";
const fallbackComponents = {
error: ({ error }: ErrorComponentProps) => {
const key = error.elementKey;
return (
<div role="alert">
{error.reason === "host-function"
? "Couldn't reach the server."
: error.message}
{error.fault !== "environment" && key && (
<button
onClick={() =>
sendRecoveryTurn({
key,
message: error.message,
reason: error.reason,
})
}
>
Recover
</button>
)}
</div>
);
},
};sendRecoveryTurn is host code: on a page it starts an edit run, in a chat it
calls sendMessage. Offer Recover for every fault but environment; an
unknown one is worth a single attempt.
3. Self-healing
With no person involved, onError sends each "document" fault as the same
recovery turn:
<RendererProvider
evaluator={evaluator}
implementations={implementations}
onError={(error) => {
if (error.fault !== "document" || !error.elementKey) {
reportToMonitoring(error);
return;
}
sendRecoveryTurn({
key: error.elementKey,
message: error.message,
reason: error.reason,
});
}}
>
<EntriesRenderer entries={entries} />
</RendererProvider>The gate is stricter than the button’s: an automatic loop sends back only what is provably the model’s.
The error slot stays until the corrected line lands.
Budget the retries. Cap attempts per elementKey at one or two. Past the
cap, leave the Recover button in place, so self-healing falls back to the user
instead of looping.