What happened?
The OpenCode TUI crashed fatally while the magic-context sidebar was rendering the recomp / session-upgrade progress section. The error toast surfaced this stack:
Error: null is not an object (evaluating 'props.progress.phase')
at phase (.../src/tui/slots/sidebar-content.tsx:376:29)
at <anonymous> (.../src/tui/slots/sidebar-content.tsx:491:38)
...
After the crash, the TUI process is gone and the session must be restarted.
Root cause
Two render sites in packages/plugin/src/tui/slots/sidebar-content.tsx (the collapsed-view and the expanded-view render of RecompProgressSection) use a fragile Solid JSX pattern:
{s()?.recompProgress && (
<RecompProgressSection theme={props.theme} progress={s()!.recompProgress!} />
)}
The guard and the prop expression read the same signal twice:
- Guard uses optional chaining (
s()?.recompProgress) — admits null | undefined.
- Prop uses non-null assertion (
s()!.recompProgress!) — assumes non-null.
- In Solid, the prop expression
progress={s()!.recompProgress!} is re-evaluated on every reactive access of props.progress inside the child (this reactivity is intentional — the original code comment at L356-L363 explains that the child MUST observe phase transitions live without remounting).
- The
recompTick resilient poll loop (L540-L606) explicitly handles transient cache misses by carrying the last good progress forward, but when both the current snapshot AND prevProgress are falsy, setSnapshot({ ...data, recompProgress: null }) is published.
- On the next reactive read inside
RecompProgressSection, props.progress evaluates s()!.recompProgress! → null → props.progress.phase throws TypeError: null is not an object.
The NonNullable<SidebarSnapshot["recompProgress"]> annotation on the component's progress prop is a compile-time promise that nothing enforces at runtime — and the repo's default bun run typecheck (tsconfig include: ["src/**/*.ts"]) and bun run lint (biome includes: ["src/**/*.ts"]) don't cover .tsx files, so this null-safety hole slipped through CI.
Expected behavior
The sidebar should never crash on a transient recompProgress: null snapshot. It should silently skip rendering the progress section until the next poll returns a non-null value.
Fix
Replace both render sites with the canonical Solid <Show> callback form, which guarantees the child callback only runs while when is truthy and the accessor returns the narrowed non-null value:
<Show when={s()?.recompProgress}>
{(progress) => (
<RecompProgressSection theme={props.theme} progress={progress()} />
)}
</Show>
This also removes two noNonNullAssertion lint warnings.
PR:
Repro
Hard to repro deterministically — requires a recomp/upgrade pass to coincide with a snapshot cache miss that publishes { recompProgress: null } while prevProgress is also falsy. Easiest path: start a /ctx-recomp, then rapidly issue concurrent snapshot fetches until one returns a fresh (no recompProgress) entry.
Diagnostics
- Plugin version: 0.25.0
- OpenCode version: 1.17.8
- Platform: macOS arm64
- Client: OpenCode TUI (CLI)
Suggested follow-up (out of scope for this fix)
packages/plugin/biome.json and packages/plugin/tsconfig.json both scope their includes to src/**/*.ts and exclude .tsx. Type and lint errors in .tsx files are not caught by bun run typecheck / bun run lint in CI. Adding src/**/*.tsx to both include lists would prevent a similar class of bug from shipping.
What happened?
The OpenCode TUI crashed fatally while the magic-context sidebar was rendering the recomp / session-upgrade progress section. The error toast surfaced this stack:
After the crash, the TUI process is gone and the session must be restarted.
Root cause
Two render sites in
packages/plugin/src/tui/slots/sidebar-content.tsx(the collapsed-view and the expanded-view render ofRecompProgressSection) use a fragile Solid JSX pattern:The guard and the prop expression read the same signal twice:
s()?.recompProgress) — admitsnull | undefined.s()!.recompProgress!) — assumes non-null.progress={s()!.recompProgress!}is re-evaluated on every reactive access ofprops.progressinside the child (this reactivity is intentional — the original code comment at L356-L363 explains that the child MUST observe phase transitions live without remounting).recompTickresilient poll loop (L540-L606) explicitly handles transient cache misses by carrying the last good progress forward, but when both the current snapshot ANDprevProgressare falsy,setSnapshot({ ...data, recompProgress: null })is published.RecompProgressSection,props.progressevaluatess()!.recompProgress!→null→props.progress.phasethrowsTypeError: null is not an object.The
NonNullable<SidebarSnapshot["recompProgress"]>annotation on the component'sprogressprop is a compile-time promise that nothing enforces at runtime — and the repo's defaultbun run typecheck(tsconfiginclude: ["src/**/*.ts"]) andbun run lint(biomeincludes: ["src/**/*.ts"]) don't cover.tsxfiles, so this null-safety hole slipped through CI.Expected behavior
The sidebar should never crash on a transient
recompProgress: nullsnapshot. It should silently skip rendering the progress section until the next poll returns a non-null value.Fix
Replace both render sites with the canonical Solid
<Show>callback form, which guarantees the child callback only runs whilewhenis truthy and the accessor returns the narrowed non-null value:This also removes two
noNonNullAssertionlint warnings.PR:
Repro
Hard to repro deterministically — requires a recomp/upgrade pass to coincide with a snapshot cache miss that publishes
{ recompProgress: null }whileprevProgressis also falsy. Easiest path: start a/ctx-recomp, then rapidly issue concurrent snapshot fetches until one returns a fresh (no recompProgress) entry.Diagnostics
Suggested follow-up (out of scope for this fix)
packages/plugin/biome.jsonandpackages/plugin/tsconfig.jsonboth scope their includes tosrc/**/*.tsand exclude.tsx. Type and lint errors in.tsxfiles are not caught bybun run typecheck/bun run lintin CI. Addingsrc/**/*.tsxto both include lists would prevent a similar class of bug from shipping.