eleven / messenger Public

web: schema apps render a virgin thread as empty, not an error (#805)

A never-used app thread has no folded state (the host only seeds on the
first event), so appStateFor hands render `undefined`. The pre-schema
hand-written To-do/Reminders renders guarded that; the schema renderer
(#478) didn't — listModel evaluated state[schema.collection] and threw,
so a fresh To-do/Reminders Interface pane showed the contained "This
view hit an error" message instead of the empty list.

Guard at the schemaRender factory seam (undefined → initialState) and
make the engine's state-taking entry points (listModel, partitionState,
tickerRules) tolerate undefined the same way, with a regression test.
Native is unaffected: SchemaAppStore seeds initialState at construction.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 4331d9ef99329f11ea2d47d9840eaa5aeb10afb4 parent 4c785c0
4 files changed, +25 −3
  • changelog/2026-07-12-todo-fresh-thread-fix.md +3 −0
  • test/schema-engine.test.mjs +12 −0
  • web/static/apps/schema-engine.js +5 −2
  • web/static/apps/schema-render.js +5 −1
diff --git a/changelog/2026-07-12-todo-fresh-thread-fix.md b/changelog/2026-07-12-todo-fresh-thread-fix.md
new file mode 100644
index 0000000..39484a6
--- /dev/null
+++ b/changelog/2026-07-12-todo-fresh-thread-fix.md
@@ -0,0 +1,3 @@
+🤖 Fixed: opening a brand-new To-do or Reminders list on the web before adding
+anything showed "This view hit an error" instead of the empty list. Fresh lists
+now open cleanly.
diff --git a/test/schema-engine.test.mjs b/test/schema-engine.test.mjs
index 1e3d60f..360ff01 100644
--- a/test/schema-engine.test.mjs
+++ b/test/schema-engine.test.mjs
@@ -208,6 +208,18 @@ await ok("todo summary cases", () => {
assert.strictEqual(listModel(todoSchema, s, NOW).summary, "all done 🎉");
});
+await ok("undefined state reads as the empty state (virgin thread)", () => {
+ // Regression: the host only seeds state on the first fold, so a never-used
+ // app thread hands the renderer `undefined` — the engine's entry points must
+ // treat that as empty, not throw ("undefined is not an object" on the
+ // Interface view of a fresh To-do/Reminders thread).
+ for (const schema of [todoSchema, remindSchema]) {
+ assert.deepStrictEqual(listModel(schema, undefined, NOW), listModel(schema, initialState(schema), NOW));
+ assert.deepStrictEqual(partitionState(schema, undefined, NOW), { active: [], grace: [], archived: [] });
+ assert.deepStrictEqual(tickerRules(schema, undefined, NOW, new Set()), []);
+ }
+});
+
await ok("search filters rows across sections", () => {
let s = initialState(todoSchema);
s = foldSchemaEvent(todoSchema, s, { type: "todo.add", id: "a", text: "buy milk" });
diff --git a/web/static/apps/schema-engine.js b/web/static/apps/schema-engine.js
index f3abaa5..58c5d52 100644
--- a/web/static/apps/schema-engine.js
+++ b/web/static/apps/schema-engine.js
@@ -282,7 +282,7 @@ function applyFoldOp(schema, state, op, event) {
export function partitionState(schema, state, now) {
const p = schema.partition;
- const items = state[schema.collection] || [];
+ const items = (state || {})[schema.collection] || [];
const active = [];
const grace = [];
const archived = [];
@@ -307,6 +307,9 @@ export function partitionState(schema, state, now) {
export function listModel(schema, state, now, ui = {}) {
const view = schema.views && schema.views.main;
if (!view) return null;
+ // A never-used app thread has no folded state at all (the host only seeds on
+ // the first event), so `state` can arrive undefined — treat it as empty.
+ state = state || initialState(schema);
const items = state[schema.collection] || [];
const buckets = partitionState(schema, state, now);
const scope = { state, items, buckets, now };
@@ -503,7 +506,7 @@ export async function runAction(schema, name, scope, io) {
export function tickerRules(schema, state, now, fired) {
const t = schema.ticker;
if (!t) return [];
- const items = state[schema.collection] || [];
+ const items = (state || {})[schema.collection] || [];
const keyField = collectionDecl(schema, schema.collection).key || "id";
const out = [];
for (const rule of t.perItem || []) {
diff --git a/web/static/apps/schema-render.js b/web/static/apps/schema-render.js
index 2e6eead..ec589eb 100644
--- a/web/static/apps/schema-render.js
+++ b/web/static/apps/schema-render.js
@@ -43,7 +43,11 @@ export function schemaInitialState(schema) {
}
export function schemaRender(schema, opts = {}) {
- return (state, el, ctx) => render(schema, state, el, ctx, opts);
+ // The host passes the raw folded state, which is undefined for a thread with
+ // no events yet (state is only seeded on the first fold). The pre-schema
+ // hand-written renders guarded that themselves; guard it here for every
+ // schema app, so a virgin thread renders as the empty state, not an error.
+ return (state, el, ctx) => render(schema, state || initialState(schema), el, ctx, opts);
}
// ---- transient UI state (search query, collapsed sections) -----------------