Skip to content

FE-778: Stop the ad-hoc form from re-rendering the editor while it is open - #9620

Open
kube wants to merge 3 commits into
mainfrom
claude/fe-778-adhoc-form-render-performance
Open

FE-778: Stop the ad-hoc form from re-rendering the editor while it is open#9620
kube wants to merge 3 commits into
mainfrom
claude/fe-778-adhoc-form-render-performance

Conversation

@kube

@kube kube commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Important

Experimental
Behind the Ad-hoc scenarios feature flag.

Summary

Before this PR, opening Simulation Settings with a scenario selected put the editor in a loop. The panel rebuilds the form's state object on every render, the form's language session synced to the worker on every new object, every sync republished the net's diagnostics, and the publish re-rendered the panel. The canvas sat at 9 fps while nothing was touched, and a node drag took 38 seconds and remounted the form twice.

Session syncs now follow the definition's content, the worker reuses the net's diagnostics across session-only syncs, the client drops publishes that change nothing, and the simulation provider is memoized again. Run-mode hosts reseed on scenario content rather than object identity, and the form indexes labels and place totals once per render. Measured with the bench/run.mjs Playwright harness on an 80-place generated net against the production website build.

Simulation Settings showing a classic scenario Before After
Idle, 3 s 9 fps, p95 frame 117 ms, 58 worker syncs per second 120 fps, p95 frame 9 ms, no syncs
Hovering nodes 8 fps, p95 frame 142 ms 101 fps, p95 frame 17 ms
Dragging a node, 30 steps 38 s, p95 frame 117 ms, form remounted twice 2.7 s, p95 frame 9 ms, no remount
Panning 16 fps, p95 frame 117 ms 115 fps, p95 frame 9 ms

Playback with the panel open runs at the same 30 fps with the flag on and off: that cost is the canvas re-rendering every node per frame, and it is untouched here.

Before

9620-before.mp4

After

9620-after.mp4

Links

Changes

Ad-hoc form

  • Language session syncs on content, not state identity

    useAdHocLspSession keys its effect on the serialized state.
    Hosts that rebuild an equal state per render no longer reach the worker.

  • Labels and place totals come from one index per render

    createAdHocTargetLabeler and createAdHocPlaceTotalResolver build their lookups once and the value cells read them from the form context.
    A count that is a number literal resolves without the sandbox.

  • Run-mode hosts reseed on scenario content

    Simulation Settings and the experiment drawer compare the selected scenario's JSON, so an edit elsewhere in the net keeps the form mounted and its undo history.

Simulation provider

  • React Compiler compiles the provider again

    A throw inside initialize's try/catch is syntax the compiler does not lower, and that one statement excluded the whole provider.
    Compiling the run's scenario moved to compileScenarioForRun, so derived values keep their identity between renders.

Language server

  • Worker caches the net's own diagnostics across session syncs

    checkSDCPN runs again only when the net or one of its documents changes.

  • Client skips a publish that changes no diagnostic

    Per-document arrays are reused when equal, and an equal snapshot never reaches the store.

Next steps

  • Playback re-renders every canvas node and arc per frame through the frame source context

    Same cost with the flag off; a separate change on the canvas side.

Review fixes

  • Count fast path resolves only finite strict-mode literals

    00 and a digit string past Number's range fall through to the evaluator and stay unresolved, as they did before the fast path.

Test coverage

  • use-ad-hoc-lsp-session.test.tsx:

    One initialize, no update for an equal state object, one update for a content change, kill on unmount.

  • language-client.test.ts:

    Equal publishes do not notify, unchanged documents keep their array identity.

  • ad-hoc-scenario.test.ts:

    Resolver and labeler match the per-place and per-target functions, literal counts resolve without evaluation.

  • Existing @hashintel/petrinaut and @hashintel/petrinaut-core unit suites
  • ad-hoc-scenario.test.ts, experiment-scenario-run.test.tsx, review fixes:

    A legacy leading zero and an overflowing literal stay unresolved; an equal-content scenario clone keeps the run form mounted and a content change reseeds it.

How to test

  • Open Petrinaut preview on Vercel
  • Menu > Load example > SIR Epidemic Model
  • Viewport controls > Settings > General > Ad-hoc scenarios
  • Bottom bar > diagnostics > Simulation Settings tab
  • Scenario > Seasonal Flu

    Expect hover, drag and pan on the canvas to stay smooth

  • Drag a node

    Expect the form to keep its state

  • Scenario > No scenario, type a count into a place

    Expect the canvas to stay smooth while typing

… open

The form's language session synced to the worker on every state identity
change; run-mode hosts rebuild an equal state per render, and each sync
republished diagnostics, which re-rendered the hosts: a loop that held the
canvas at 9 fps for as long as Simulation Settings showed a scenario. The
session now follows the definition's content.

The simulation provider was excluded from React Compiler by a throw inside
initialize's try/catch, so every render handed out new identities for all
derived values; the compile-or-throw step moved to a helper. The worker
reuses the net's own diagnostics across session-only syncs, and the client
drops publishes that change nothing. Run-mode hosts reseed on scenario
content, not object identity, so a node drag no longer remounts the form.
Place totals and slot labels index the state once per render.
@kube kube self-assigned this Sep 9, 2026
@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
hash Ready Ready Preview Sep 9, 2026 2:45pm UTC
petrinaut Ready Ready Preview Sep 9, 2026 2:45pm UTC
petrinaut-docs Ready Ready Preview Sep 9, 2026 2:45pm UTC
1 Skipped Deployment
Project Deployment Actions Updated
hashdotdesign-tokens Ignored Ignored Preview Sep 9, 2026 2:45pm UTC

Request Review

@github-actions github-actions Bot added area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team labels Sep 9, 2026
@semgrep-code-hashintel

Copy link
Copy Markdown

Semgrep found 1 detect-non-literal-regexp finding:

  • libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts

RegExp() called with a count function argument, this might allow an attacker to cause a Regular Expression Denial-of-Service (ReDoS) within your application as RegExP blocks the main thread. For this reason, it is recommended to use hardcoded regexes instead. If your regex is run on user-controlled input, consider performing input validation or use a regex checking/sanitization library such as https://www.npmjs.com/package/recheck to verify that the regex does not appear vulnerable to ReDoS.

View Dataflow Graph
flowchart LR
    classDef invis fill:white, stroke: none
    classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none

    subgraph File0["<b>libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts</b>"]
        direction LR
        %% Source

        subgraph Source
            direction LR

            v0["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L915 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 915] count</a>"]
        end
        %% Intermediate

        subgraph Traces0[Traces]
            direction TB

            v2["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L915 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 915] count</a>"]

            v3["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L984 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 984] resolveCount</a>"]

            v4["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L931 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 931] inline</a>"]

            v5["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L885 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 885] state</a>"]

            v6["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L890 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 890] withNeutralAdHocExpressions</a>"]

            v7["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L789 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 789] state</a>"]

            v8["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L890 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 890] normalized</a>"]

            v9["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L945 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 945] placeState</a>"]

            v10["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L972 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 972] placeVariableProductions</a>"]

            v11["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L699 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 699] placeState</a>"]

            v12["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L704 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 704] of</a>"]

            v13["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L704 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 704] variable</a>"]

            v14["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L725 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 725] productions</a>"]

            v15["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L714 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 714] of</a>"]

            v16["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L714 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 714] earlier</a>"]

            v17["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L720 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 720] bareReferencePattern</a>"]

            v18["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L674 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 674] name</a>"]

            v19["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L676 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 676] escapeForPattern</a>"]

            v20["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L670 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 670] name</a>"]

            v21["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L676 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 676] `</a>"]
        end
            v2 --> v3
            v3 --> v4
            v4 --> v5
            v5 --> v6
            v6 --> v7
            v7 --> v8
            v8 --> v9
            v9 --> v10
            v10 --> v11
            v11 --> v12
            v12 --> v13
            v13 --> v14
            v14 --> v15
            v15 --> v16
            v16 --> v17
            v17 --> v18
            v18 --> v19
            v19 --> v20
            v20 --> v21
        %% Sink

        subgraph Sink
            direction LR

            v1["<a href=https://github.com/hashintel/hash/blob/dfe685f9e24c87578f036338cb8ea4c0b951e141/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/ad-hoc/ad-hoc-scenario.ts#L675 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 675] new RegExp(<br>    String.raw`(?&lt;![.\w$])${escapeForPattern(name)}(?![A-Za-z0-9_$])`,<br>    &quot;g&quot;,<br>  )</a>"]
        end
    end
    %% Class Assignment
    Source:::invis
    Sink:::invis

    Traces0:::invis
    File0:::invis

    %% Connections

    Source --> Traces0
    Traces0 --> Sink

Loading

@kube
kube marked this pull request as ready for review September 9, 2026 12:26
@kube
kube requested review from YannisZa, drdma and vilkinsons and a balanced review from Copilot and removed request for Copilot September 9, 2026 12:26
@cursor

cursor Bot commented Sep 9, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Performance and render-stability changes with targeted tests; LSP caching invalidates on net edits and preserves diagnostic semantics when content is unchanged.

Overview
Fixes a feedback loop when Simulation Settings shows a scenario through the ad-hoc form: hosts rebuilt state on every render, the LSP session synced on object identity, each sync republished diagnostics, and subscribers re-rendered the whole panel (and canvas).

Ad-hoc form & hosts: useAdHocLspSession now keys worker updates on serialized state, not reference equality. Simulation Settings and experiment run UI reseed the form only when scenario content changes (JSON.stringify), not when the document hands out a new object for unrelated edits. The form builds createAdHocTargetLabeler and createAdHocPlaceTotalResolver once per render and passes labelFor / placeTotal through context so labelling and totals are linear in net size (with a numeric-literal fast path for counts).

LSP: The worker caches net-level checkSDCPN results across ad-hoc/scenario session syncs and clears the cache when the net or a non-temp document changes. The client reuses unchanged per-URI diagnostic arrays and drops publishes whose snapshot is identical, so keystroke-driven publishes do not notify unchanged consumers.

Simulation provider: Scenario compile failure handling moves to compileScenarioForRun outside initialize's try/catch so React Compiler can memoize the provider again.

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

@kube
kube added this pull request to stack #9629 September 9, 2026 12:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The numeric-literal fast path can resolve invalid or non-finite expressions incorrectly, and the host reseeding behavior lacks a regression test.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Fixes ad-hoc scenario forms repeatedly re-rendering the editor by stabilizing session synchronization, diagnostics, and scenario reseeding.

Changes:

  • Keys form sessions and run-mode state by serialized content.
  • Caches diagnostics and indexed form lookups.
  • Adds regression tests and package changesets.
  • No user-guide documentation updates are included.
File summaries
File Description
experiment-scenario-run.tsx Reseeds experiment forms by scenario content.
simulation-settings.tsx Preserves forms across unrelated net edits.
value-editor.tsx Uses indexed target labels.
use-ad-hoc-lsp-session.ts Syncs sessions only on content changes.
use-ad-hoc-lsp-session.test.tsx Tests session synchronization lifecycle.
token-table.tsx Uses cached place totals.
place-block.tsx Uses cached place totals.
form-context.ts Exposes label and total resolvers.
ad-hoc-scenario-form.tsx Creates shared per-render indexes.
provider.tsx Restores React Compiler optimization.
ad-hoc-scenario.ts Adds indexed resolvers and literal fast path.
ad-hoc-scenario.test.ts Tests resolver behavior.
language-server.worker.ts Caches net diagnostics.
language-client.ts Suppresses unchanged diagnostic snapshots.
language-client.test.ts Tests diagnostic identity and notifications.
index.ts Exports the new resolver APIs.
adhoc-form-render-performance.md Adds Petrinaut patch changeset.
adhoc-form-render-performance-core.md Adds Petrinaut Core patch changeset.
Review details
  • Files reviewed: 18/18 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/infra Relates to version control, CI, CD or IaC (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

3 participants