Skip to content

TUI fatal crash: null is not an object (evaluating 'props.progress.phase') in RecompProgressSection #168

@wjiuxing

Description

@wjiuxing

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:

  1. Guard uses optional chaining (s()?.recompProgress) — admits null | undefined.
  2. Prop uses non-null assertion (s()!.recompProgress!) — assumes non-null.
  3. 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).
  4. 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.
  5. On the next reactive read inside RecompProgressSection, props.progress evaluates s()!.recompProgress!nullprops.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions