Skip to content

web: StrictMode-safe runtime lifecycle + progressive rendering + state dedupe#3

Open
ragnorc wants to merge 1 commit into
ragnorc/dac-to-colombo-portfrom
ragnorc/colombo-web-fixes
Open

web: StrictMode-safe runtime lifecycle + progressive rendering + state dedupe#3
ragnorc wants to merge 1 commit into
ragnorc/dac-to-colombo-portfrom
ragnorc/colombo-web-fixes

Conversation

@ragnorc

@ragnorc ragnorc commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Follow-ups to #2 (stacked on its branch). Makes the web renderer actually work.

Fixes

  1. StrictMode-safe runtime lifecycle. RuntimeApp now creates the runtime inside the effect (not useState) and main.tsx restores <React.StrictMode>. Previously StrictMode's dev mount→cleanup→mount ran the cleanup (runtime.dispose()) on the single useState runtime, then re-subscribed to the now-disposed runtime — which never notified, leaving the page stuck on the loading skeleton. Each mount now builds a fresh runtime; the disposed one's in-flight reads are aborted.
  2. Progressive per-cell rendering. The page no longer gates on overall status === "ready" (all-or-nothing). It renders every cell as soon as the snapshot has them, each showing its own loading / data / error state — so a slow or failed cell never blanks the whole page. (The runtime already notifies per-cell via setCellExecution.)
  3. No-op state-change dedupe. runtime.applyStateChanges drops changes whose value equals the current state at that pointer. This breaks the web Select re-run loop (the @json-render/react control re-emits its current value on every render → applyStateChanges → re-run → re-render → …). Harmless for the TUI.

Plus: documented ReadOutput.target as the normalized branch/snapshot label. After the SDK migration the facade already collapses the server's {branch, snapshot} to a string, and no consumer reads the structured form (verified — it's set/passed-through only), so the full string → object restructure (which would ripple through catalog/runtime/client/fixture + ~13 test fixtures) is intentionally not done — it'd be churn for a display-only field. Reopen if a consumer ever needs branch-vs-snapshot.

Verification

  • @omnigraph/runtime typecheck + 13 tests (the dedupe doesn't regress existing state-change tests).
  • @omnigraph/web typecheck + 4 tests; pnpm -r build green.
  • Live web smoke still pending — my browser tooling was unresponsive this session. Quick self-contained check (no server/token needed): pnpm --filter @omnigraph/web dev then open http://127.0.0.1:5173/?mode=fixture — the company demo (which has a Select control) should render all cells with StrictMode on and no re-run loop.

Merge after #2 (or retarget to main once #2 lands).


Note

Medium Risk
Touches core runtime state application and web lifecycle; dedupe could skip legitimate updates if object key order differs, but scope is UI/runtime integration rather than auth or data persistence.

Overview
Makes the web notebook renderer usable under React StrictMode and avoids blank pages while cells load.

Runtime lifecycle: RuntimeApp creates the notebook runtime inside a useEffect (with runtimeRef) instead of useState, so StrictMode’s mount → cleanup → remount disposes the first instance and subscribes to a fresh one. main.tsx re-enables <React.StrictMode> and drops the dev workaround comment.

Progressive UI: The app no longer waits for overall status === "ready" before showing cells. It renders the cell list whenever a snapshot exists (only null snapshot or fatal use full-page states). CellCard shows per-cell skeleton bars while a data cell has no spec yet.

State loop fix: applyStateChanges filters out changes whose value already matches state at that JSON pointer (valuesEqual, including shallow object compare via JSON.stringify), so controls that re-emit the same value on every render do not re-trigger dependent cell runs.

Docs only: JSDoc on ReadOutput.target explains it as the normalized branch/snapshot label string.

Reviewed by Cursor Bugbot for commit 6a21af4. Bugbot is set up for automated code reviews on this repo. Configure here.

Greptile Summary

This PR fixes three problems introduced by the SDK migration in #2: a StrictMode double-invoke that left the page on a loading skeleton forever, an all-or-nothing rendering gate that blanked the page when any cell was slow, and an infinite re-run loop caused by a bound Select control re-emitting its current value on every render.

  • StrictMode-safe lifecycle (App.tsx, main.tsx): the runtime is now created inside useEffect so each mount builds a fresh runtime rather than re-subscribing to a disposed one; <React.StrictMode> is restored.
  • Progressive per-cell rendering (App.tsx): snapshot === null (pre-effect) shows the full skeleton; once the runtime is ready the page renders cells immediately with per-cell skeleton bars for unresolved data cells, so a slow or failed cell never blanks the whole page.
  • State-change deduplication (runtime/index.ts): applyStateChanges filters out changes whose value already equals the current state pointer value, breaking the Select re-run loop; valuesEqual handles primitives via === and objects via JSON.stringify.

Confidence Score: 4/5

The three core fixes are correctly implemented; the only rough edge is the JSON.stringify-based equality in valuesEqual, which is order-sensitive for object keys and conflates undefined/NaN with null.

The StrictMode lifecycle and progressive rendering changes are solid. The valuesEqual helper has known limitations with JSON.stringify that could cause the dedup to silently fail or produce false positives in edge cases, but these do not affect the primary Select re-run loop fix for typical string values.

packages/runtime/src/index.ts — specifically the new valuesEqual helper; the JSON.stringify approach warrants a closer look if any control state values are objects.

Important Files Changed

Filename Overview
packages/runtime/src/index.ts Adds JSDoc to ReadOutput.target and introduces applyStateChanges no-op deduplication via a new valuesEqual helper; the logic is sound for primitive and well-ordered object values, but JSON.stringify-based equality has edge cases with key ordering and NaN/undefined coercion.
packages/web/src/App.tsx RuntimeApp refactored to create the runtime inside useEffect (StrictMode-safe), switch snapshot to nullable, and render cells progressively — the lifecycle is correct and the ref-based handler pattern is idiomatic.
packages/web/src/main.tsx Restores React.StrictMode wrapping now that the lifecycle fix in App.tsx makes it safe to do so; minimal and correct change.

Sequence Diagram

sequenceDiagram
    participant SM as React StrictMode
    participant RA as RuntimeApp
    participant RT as NotebookRuntime
    participant UI as Progressive UI

    SM->>RA: mount (1st — dev only)
    RA->>RT: createNotebookRuntime()
    RT-->>RA: "runtime A (cells pre-populated, status=loading)"
    RA->>RA: setSnapshot(runtime A snapshot)
    RA->>RT: runtime A.subscribe(setSnapshot)
    SM->>RA: cleanup (dev only)
    RA->>RT: unsubscribe + runtime A.dispose()
    RA->>RA: "runtimeRef.current = null"

    SM->>RA: mount (2nd — real mount)
    RA->>RT: createNotebookRuntime()
    RT-->>RA: runtime B (fresh, cells loading)
    RA->>RA: setSnapshot(runtime B snapshot) → shows per-cell skeletons
    RA->>RT: runtime B.subscribe(setSnapshot)

    RT->>RT: async cell reads complete
    RT->>RA: notify() → setSnapshot(updated cells)
    RA->>UI: render cell (spec ready)

    UI->>RA: onStateChange(changes)
    RA->>RT: runtimeRef.current?.applyStateChanges(changes)
    RT->>RT: valuesEqual check — drop no-op changes
    RT->>RT: rerunCells(affected) only if changed
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "web: StrictMode-safe runtime lifecycle, ..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

…dedupe

Addresses the three follow-ups noted in the SDK-adoption PR:

- web/App: create the runtime inside the effect (not useState) and restore
  React.StrictMode. StrictMode's dev mount→cleanup→mount used to dispose the
  one runtime and re-subscribe to it, wedging the page on the loading skeleton;
  now each mount builds a fresh runtime.
- web/App: render every cell progressively — each shows its own loading / data
  / error state — instead of gating the whole page on overall status. A slow or
  failed cell no longer blanks the page (the all-or-nothing gate).
- runtime: applyStateChanges drops no-op changes, so a bound control re-emitting
  its current value (the web Select) no longer triggers an endless re-run loop.
- runtime: document ReadOutput.target as the normalized branch/snapshot label —
  the facade collapses the server's {branch,snapshot} and no consumer needs the
  structured form, so the full string→object restructure is intentionally not
  done (it would ripple through 4 packages + tests for a display-only field).

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6a21af4. Configure here.

resolveStatePointer(this.snapshot.state, change.path),
change.value,
),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Batch no-op filter wrong

Low Severity

In applyStateChanges, each incoming change is compared to the pre-batch snapshot state when deciding if it is a no-op. A later entry for the same JSON pointer that matches the original value can be dropped even though an earlier entry in the same batch already changed that pointer, so the final state may not match applying all patches in order.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6a21af4. Configure here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6a21af45ff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +247 to +253
const effective = changes.filter(
(change) =>
!valuesEqual(
resolveStatePointer(this.snapshot.state, change.path),
change.value,
),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve overlapping state changes in a batch

When a single applyStateChanges call contains overlapping paths, this filter compares every change to the pre-patch snapshot, so it can drop a child update that is only a no-op before an earlier parent update is applied. For example, starting from {filters:{status:"open"}}, a patch like [{path:"/filters", value:{}}, {path:"/filters/status", value:"open"}] should leave status restored, but the second change is filtered out here and the final state becomes {filters:{}}, causing dependent cells to rerun with the wrong params. The dedupe needs to evaluate changes sequentially against the evolving next state, or avoid dropping changes whose paths overlap another change in the same batch.

Useful? React with 👍 / 👎.

Comment on lines +896 to +902
function valuesEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a !== null && b !== null && typeof a === "object" && typeof b === "object") {
return JSON.stringify(a) === JSON.stringify(b);
}
return false;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 valuesEqual has two edge cases that can silently misclassify changes

JSON.stringify is key-insertion-order sensitive: valuesEqual({a:1, b:2}, {b:2, a:1}) returns false even though the values are semantically identical. If @json-render/react's Select constructs a new object from the stored value with a different property order on re-emit, the dedupe check fails and the loop recurs. Additionally, JSON.stringify maps both undefined and NaN to null, so valuesEqual({x: undefined}, {x: null}) returns true, silently dropping a genuine state change. Consider replacing JSON.stringify with a proper structural comparison (e.g. a recursive key-sorted comparison or a well-tested deep-equal utility) to avoid both pitfalls.

Fix in Claude Code

Comment thread packages/web/src/App.tsx
Comment on lines 97 to 105
const handlers = useMemo(
() => ({
mutate: async (params: Record<string, unknown>) => {
setDismissedMutationError(null);
await runtime.dispatch("mutate", { params });
await runtimeRef.current?.dispatch("mutate", { params });
},
}),
[runtime],
[],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 handlers and handleStateChange correctly use ref, but stale-closure risk on setDismissedMutationError

The empty dependency arrays are correct because runtimeRef is a mutable ref. However, handlers.mutate also closes over setDismissedMutationError, which is a useState setter and is guaranteed stable by React — so this works today. The pattern is worth a brief comment to clarify intent; without one, a future reader might add runtimeRef to the dep array (causing the runtime to re-create on every effect cycle), or might add a real closure over snapshot instead of reading via the ref.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 4 additional findings.

Open in Devin Review

aaltshuler added a commit that referenced this pull request Jun 25, 2026
- Text/Quote `text_column` is now required (z.string().min(1)) — a Text/Quote
  cell with rows but no text column was silently rendering the empty fallback
  instead of failing validation. (#3)
- validate: warn when a *required* (non-nullable) catalog param is bound to
  $state without a default — it resolves at runtime, which validate can't see,
  so flag it rather than passing silently. (#2)
- validate: document that the server-bound checks are intentional — structural
  parse already runs offline above; the meaningful ref/param validation needs a
  resolvable source by design. (#1, accepted-by-design)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant