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>
4 files changed,
+25
−3
changelog/2026-07-12-todo-fresh-thread-fix.md+3 −0test/schema-engine.test.mjs+12 −0web/static/apps/schema-engine.js+5 −2web/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) ----------------- |