eleven / ocho 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) -----------------