This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-8845-57faa6b85c72bdd258b8e735c4be13171b5f8443
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 3726dd7b1de85c649a3d953cfa4ee17479c89df6
Author: yangzhang75 <[email protected]>
AuthorDate: Sun Oct 18 02:58:35 2026 +0000

    fix(workflow-form): rebuild the cards on compilation only when it changed 
something they show (#8845)
    
    ### What changes were proposed in this PR?
    
    Closes #8844. Part of the Form View feature (#8011); from the #8439
    refinements. Found on the #8761 demo (#8843).
    
    A compilation result rebuilt every card on the Form View, including the
    compilation a card's own write triggers. Three things followed from
    that, all visible with a Projection's drag-reorder list (#8761) and with
    any tick box: the controls were redrawn under the reader's eyes after
    every click (the row buttons flicker); the keyboard focus went with the
    replaced node, so a keyboard reader landed on the body about a second
    after every Move up / Move down; and a click made in the last 150 ms
    before the rebuild was lost, since the rebuild ended that card's pending
    debounced write and re-seeded the card from the operator, which still
    held the previous state (the row "jumps back"). The canvas's property
    panel redraws only when an operator's schema changes.
    
    The form now does the same:
    
    - A compilation rebuilds the cards only when it changed something they
    show: a card's operator schema (the column enums compilation writes into
    it, which turn an attribute box into a dropdown), the set of inputs (an
    operator deleted on the canvas makes its card broken; a property exposed
    or removed), or an operator value a card does not hold (a co-editor's
    edit), including the operator's other properties, which a card is seeded
    with as the context a custom widget reads to decide what it shows (the
    HuggingFace picker reads `task`): nothing but a rebuild re-seeds those,
    so a sibling changed on the canvas or by a co-editor would have left the
    card showing the old one (Copilot). Every property an input is bound to
    is left out of that context, this card's own and any other card's on the
    same step, since each is compared against the card that shows it:
    counting them as context too would make one card's edit a change to
    every other card of that step, and a form with two inputs from one step
    would rebuild wholesale on every compilation its own typing triggers --
    the churn this PR exists to stop (Copilot). The keys are compared
    sorted, so a rewrite that only reorders them is not a change, and a
    card's own edit, written back or still on its way, is not a reason: the
    two agree once it is written. The result picker and the shown results
    still follow the graph on every compilation.
    - Before any rebuild, from any source, a card's unwritten edit is
    written, so a rebuild can no longer lose one.
    - An empty string and an absent value count as the same value in the
    comparison (formly's blank default for a property the operator never
    had), so such a card is not rebuilt on every compilation.
    
    ### Any related issues, documentation, discussions?
    
    Closes #8844. The drag-reorder list #8843 (#8761) brings to the form is
    where this is plain to see: every drop writes and compiles, and the rows
    redraw about a second later; a second drop made just before that redraw
    is undone. The cause is the form's own compile handling, present since
    the cards were first built from the compiled schema; on main alone a
    tick box goes through the same redraw, focus drop and lost second click,
    which take a keyboard or a stopwatch to notice. The Before/After below
    is therefore recorded on top of #8843 (before: #8843 alone; after: #8843
    plus this); this PR does not depend on #8843 and applies to main as is.
    Known limitation, unchanged: a card still shows a co-editor's edit of
    the same property only after the rebuild that edit's compilation
    triggers (tracked as a follow-up).
    
    ### How was this PR tested?
    
    Unit tests (vitest), the form's spec: a compilation that changes nothing
    a card shows leaves the cards alone (same card objects) while the result
    picker is still refreshed; a changed operator schema, an operator gone
    from the graph, an operator value changed under a card (a co-editor's
    edit), another property of a card's operator changed (the widget
    context), a changed set of inputs (one exposed; a shown one turned
    broken) each rebuild; the same properties in another order do not, nor
    does a change to the card's own property while its edit is still on its
    way, nor one card's edit when two inputs come from the same step (both
    card objects survive it); an author's broken card is left alone; the
    card's own edit does not rebuild while pending nor once written;
    formly's blank and an absent value count as the same; an unwritten edit
    is written, once, before a rebuild from any source replaces the card; a
    read-only viewer's card has no write to flush; the typing hold still
    holds a needed rebuild until the focus leaves. Each guard was
    deletion-checked (every compilation rebuilding; the schema check, the
    context check, the exclusion of every bound property from it, that the
    exclusion covers the other cards of the step and not only this one, the
    context's key sort, the pending check, the broken-set check, the blank
    normalisation, the pre-rebuild flush, the pending mark, the pending
    reset and the result-picker refresh each removed; a broken card checked
    against a schema) and turned a named test red. eslint, prettier and the
    production (AOT) build pass; the full frontend suite passes; every
    statement, function and branch this PR adds is covered.
    
    Checked on a flag-on instance (main-demo; before = main, and main +
    #8843; after = the same plus this), a Projection's columns and its Drop
    Option exposed on the form. The tick box on main: 0.3 s after a tick the
    box still has the focus; 2.8 s later it is a new DOM node and the focus
    is on the body; two ticks 700 ms apart end in the state after the first
    (the second undone). With this change the box is the same node three
    seconds later, still focused, and both ticks land. The drag-reorder rows
    on main + #8843: after a drop the rows are replaced about a second
    later; with this change they are the same nodes, and the order still
    reaches a canvas tab open beside the form within a second, a drop made
    on the canvas panel still reaches the form (a value the card does not
    hold rebuilds it), and the saved workflow carries the order.
    
    #### Video
    
    **Before**
    
    
    
https://github.com/user-attachments/assets/eac466c3-c0c9-4969-b39a-a6da41ec54ff
    
    **After**
    
    
    
    
https://github.com/user-attachments/assets/3ecd6342-7d6b-405c-bd83-f45415c2bcba
    
    
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Yes. Generated-by: Claude Code (Claude Fable 5.1, Anthropic).
    Co-authored with Claude, reviewed line by line by the author before
    submission.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-authored-by: Claude Fable 5.1 <[email protected]>
---
 .../workflow-form/workflow-form.component.spec.ts  | 308 ++++++++++++++++++++-
 .../workflow-form/workflow-form.component.ts       | 196 +++++++++++--
 2 files changed, 470 insertions(+), 34 deletions(-)

diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
index 6273ffc92b..8b2975dace 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts
@@ -1070,6 +1070,22 @@ describe("WorkflowFormComponent", () => {
       expect(formBindingService.writeValue).toHaveBeenCalled();
     });
 
+    it("keeps a still-set value when a control emits an absent value before an 
edit", () => {
+      build(formViewWorkflow).ngOnInit();
+      formBindingService.readValue.mockReturnValue("seed");
+      renderOne("n_hvg");
+      const card = component.rendered[0];
+      const key = card.resolved.binding.id;
+      vi.useFakeTimers();
+
+      card.model[key] = undefined;
+      card.form.addControl(key, new FormControl(null));
+      vi.advanceTimersByTime(FORM_DEBOUNCE_TIME_MS + 50);
+      vi.useRealTimers();
+
+      expect(formBindingService.writeValue).not.toHaveBeenCalled();
+    });
+
     it("ignores an unchanged form emission", () => {
       build(formViewWorkflow).ngOnInit();
       formBindingService.readValue.mockReturnValue("seed");
@@ -1434,14 +1450,293 @@ describe("WorkflowFormComponent", () => {
   });
 
   describe("keeping the inputs in step with the workflow", () => {
-    it("rebuilds the inputs when compilation reports a new state", async () => 
{
+    // Put op-1 on the graph with one exposed property holding `value`, and 
build its card.
+    const renderCard = (id: string, value: unknown) => {
+      h.hasOperatorIds.add("op-1");
+      formBindingService.resolveFields.mockReturnValue([resolved(id, id, { 
value })]);
+      formBindingService.readValue.mockReturnValue(value);
+      (component as any).readConfig();
+    };
+    const compiled = async () => {
+      h.compilationChanged.next("Succeeded");
+      await new Promise(r => setTimeout(r, FORM_DEBOUNCE_TIME_MS + 50));
+    };
+    // Compilation wrote column enums into op-1's schema: the card was built 
from a schema without them.
+    const changeSchema = () => {
+      (component as any).dynamicSchemaService = {
+        getDynamicSchema: () => ({ jsonSchema: { properties: { n_hvg: { enum: 
["a", "b"] } } } }),
+      };
+    };
+
+    // A compilation changes nothing for most cards -- the one a card's own 
write triggers, for one.
+    // Rebuilding them anyway redrew every control, dropped the focus and lost 
a click made just
+    // before the rebuild; the canvas panel redraws only for a schema change, 
and so does the form.
+    it("leaves the cards alone when compilation changed nothing they show, as 
the canvas panel does", async () => {
       build(formViewWorkflow).ngOnInit();
+      renderCard("n_hvg", "2000");
+      const card = component.rendered[0];
       const rebuild = vi.spyOn(component as any, "readConfig");
+      const choices = vi.spyOn(component as any, "rebuildResultChoices");
 
-      h.compilationChanged.next("Succeeded");
-      await new Promise(r => setTimeout(r, FORM_DEBOUNCE_TIME_MS + 50));
+      await compiled();
+
+      expect(rebuild).not.toHaveBeenCalled();
+      expect(component.rendered[0]).toBe(card);
+      // The result picker still follows the graph the compilation came from.
+      expect(choices).toHaveBeenCalledTimes(1);
+    });
+
+    it("rebuilds the inputs when compilation changed a card's operator schema 
(the column enums)", async () => {
+      build(formViewWorkflow).ngOnInit();
+      renderCard("n_hvg", "2000");
+      const rebuild = vi.spyOn(component as any, "readConfig");
+      changeSchema();
+
+      await compiled();
+
+      expect(rebuild).toHaveBeenCalledTimes(1);
+    });
+
+    it("rebuilds the inputs when a card's operator left the graph", async () 
=> {
+      build(formViewWorkflow).ngOnInit();
+      renderCard("n_hvg", "2000");
+      const rebuild = vi.spyOn(component as any, "readConfig");
+      h.hasOperatorIds.delete("op-1");
+
+      await compiled();
+
+      expect(rebuild).toHaveBeenCalledTimes(1);
+    });
+
+    it("rebuilds the inputs when an operator's value changed under a card (a 
co-editor's edit)", async () => {
+      build(formViewWorkflow).ngOnInit();
+      renderCard("n_hvg", "2000");
+      const rebuild = vi.spyOn(component as any, "readConfig");
+      formBindingService.readValue.mockReturnValue("3000");
+
+      await compiled();
+
+      expect(rebuild).toHaveBeenCalledTimes(1);
+    });
+
+    // A card is seeded with its operator's other properties as widget context 
(the HuggingFace
+    // picker reads `task` to decide which models to load). Nothing but a 
rebuild re-seeds that, so
+    // a change the canvas or a co-editor makes to a sibling has to count as a 
change the card shows.
+    it("rebuilds the inputs when another property of a card's operator changed 
(the widget context)", async () => {
+      build(formViewWorkflow).ngOnInit();
+      h.graphOperators.push({
+        operatorID: "op-1",
+        operatorType: "HuggingFace",
+        operatorProperties: { n_hvg: "2000", task: "image-classification" },
+      });
+      renderCard("n_hvg", "2000");
+      const rebuild = vi.spyOn(component as any, "readConfig");
+      h.graphOperators[0].operatorProperties = { n_hvg: "2000", task: 
"text-generation" };
+
+      await compiled();
+
+      expect(rebuild).toHaveBeenCalledTimes(1);
+    });
+
+    // Two inputs from one step: each card's own property is the other card's 
sibling, so counting
+    // bound properties as context would make one card's edit rebuild the 
other -- and with it the
+    // card being edited, which is the churn this PR exists to stop (Copilot).
+    it("leaves both cards alone when one of two inputs from the same step is 
edited", async () => {
+      build(formViewWorkflow).ngOnInit();
+      h.hasOperatorIds.add("op-1");
+      h.graphOperators.push({
+        operatorID: "op-1",
+        operatorType: "Projection",
+        operatorProperties: { attributes: ["a"], isDrop: false },
+      });
+      h.formlyJsonschema.toFieldConfig = () => ({
+        fieldGroup: [
+          { key: "attributes", props: { label: "Columns" } },
+          { key: "isDrop", props: { label: "Drop" } },
+        ],
+      });
+      formBindingService.resolveFields.mockReturnValue([
+        resolved("attributes", "attributes", { value: ["a"] }),
+        resolved("isDrop", "isDrop", { value: false }),
+      ]);
+      formBindingService.readValue.mockImplementation((_op: string, key: 
string) =>
+        key === "attributes" ? ["a"] : false
+      );
+      (component as any).readConfig();
+      expect(component.rendered).toHaveLength(2);
+      const cards = [...component.rendered];
+      const rebuild = vi.spyOn(component as any, "readConfig");
+
+      // One card's edit reaches the operator, and the compilation it triggers 
comes back.
+      h.graphOperators[0].operatorProperties = { attributes: ["a", "b"], 
isDrop: false };
+      formBindingService.readValue.mockImplementation((_op: string, key: 
string) =>
+        key === "attributes" ? ["a", "b"] : false
+      );
+      cards[0].model["attributes"] = ["a", "b"];
+
+      await compiled();
 
-      expect(rebuild).toHaveBeenCalled();
+      expect(rebuild).not.toHaveBeenCalled();
+      expect(component.rendered[0]).toBe(cards[0]);
+      expect(component.rendered[1]).toBe(cards[1]);
+    });
+
+    it("leaves the cards alone when the operator's properties only came back 
in another order", async () => {
+      build(formViewWorkflow).ngOnInit();
+      h.graphOperators.push({
+        operatorID: "op-1",
+        operatorType: "HuggingFace",
+        operatorProperties: { n_hvg: "2000", task: "image-classification", 
device: "cpu" },
+      });
+      renderCard("n_hvg", "2000");
+      const rebuild = vi.spyOn(component as any, "readConfig");
+      // The same properties, the two siblings swapped: a rewrite that 
reorders keys is not a change.
+      h.graphOperators[0].operatorProperties = { device: "cpu", task: 
"image-classification", n_hvg: "2000" };
+
+      await compiled();
+
+      expect(rebuild).not.toHaveBeenCalled();
+    });
+
+    // Every bound property is compared against the card that shows it, so 
none of them is context:
+    // the card's own stays the card's to resolve while its edit is on its way.
+    it("leaves the card's own property out of the context, so a pending edit 
still wins", async () => {
+      build(formViewWorkflow).ngOnInit();
+      h.graphOperators.push({
+        operatorID: "op-1",
+        operatorType: "HuggingFace",
+        operatorProperties: { n_hvg: "2000", task: "image-classification" },
+      });
+      renderCard("n_hvg", "2000");
+      const rebuild = vi.spyOn(component as any, "readConfig");
+      component.rendered[0].pendingWrite = true;
+      h.graphOperators[0].operatorProperties = { n_hvg: "9999", task: 
"image-classification" };
+      formBindingService.readValue.mockReturnValue("9999");
+
+      await compiled();
+
+      expect(rebuild).not.toHaveBeenCalled();
+    });
+
+    // The graph throws for an operator that is gone, so the context is read 
behind a hasOperator
+    // guard. A card whose operator went is already a changed set of inputs, 
so this is the guard
+    // speaking for a state the rebuild check does not otherwise reach.
+    it("reads no context for an operator that is no longer on the graph", () 
=> {
+      build(formViewWorkflow).ngOnInit();
+
+      expect((component as any).contextKeyFor("gone", "n_hvg")).toBe("[]");
+    });
+
+    it("takes formly's blank and an absent operator value as the same value", 
() => {
+      build(formViewWorkflow).ngOnInit();
+      renderCard("n_hvg", undefined);
+      const card = component.rendered[0];
+
+      card.model[card.resolved.binding.id] = "";
+      expect((component as any).cardsNeedRebuild()).toBe(false);
+
+      card.model[card.resolved.binding.id] = undefined;
+      formBindingService.readValue.mockReturnValue("");
+      expect((component as any).cardsNeedRebuild()).toBe(false);
+    });
+
+    it("rebuilds the inputs when the set of inputs changed (one more 
exposed)", async () => {
+      build(formViewWorkflow).ngOnInit();
+      renderCard("n_hvg", "2000");
+      const rebuild = vi.spyOn(component as any, "readConfig");
+      formBindingService.resolveFields.mockReturnValue([
+        resolved("n_hvg", "n_hvg", { value: "2000" }),
+        resolved("tags", "tags", { value: [] }),
+      ]);
+
+      await compiled();
+
+      expect(rebuild).toHaveBeenCalledTimes(1);
+    });
+
+    it("rebuilds the inputs when a shown card's operator was deleted (the card 
turns broken)", async () => {
+      build(formViewWorkflow).ngOnInit();
+      component.authoring = true;
+      renderCard("n_hvg", "2000");
+      const rebuild = vi.spyOn(component as any, "readConfig");
+      // Same input, same id, same value on the operator; only its 
brokenReason is new.
+      formBindingService.resolveFields.mockReturnValue([
+        resolved("n_hvg", "n_hvg", { value: "2000", brokenReason: "The step 
was removed" }),
+      ]);
+
+      await compiled();
+
+      expect(rebuild).toHaveBeenCalledTimes(1);
+    });
+
+    it("leaves an author's broken card alone: it has no schema to follow", 
async () => {
+      build(formViewWorkflow).ngOnInit();
+      component.authoring = true;
+      h.hasOperatorIds.add("op-1");
+      formBindingService.resolveFields.mockReturnValue([
+        resolved("n_hvg", "n_hvg", { value: "2000", brokenReason: "The step 
was removed" }),
+      ]);
+      (component as any).readConfig();
+      const rebuild = vi.spyOn(component as any, "readConfig");
+
+      await compiled();
+
+      expect(rebuild).not.toHaveBeenCalled();
+    });
+
+    it("does not rebuild for the card's own edit still on its way to the 
operator, nor once it is written", () => {
+      build(formViewWorkflow).ngOnInit();
+      renderCard("n_hvg", "2000");
+      const card = component.rendered[0];
+      const key = card.resolved.binding.id;
+      vi.useFakeTimers();
+
+      // The first change marks the edit pending; the operator still holds the 
old value.
+      card.model[key] = "2500";
+      card.form.addControl(key, new FormControl("2500"));
+      card.form.markAsDirty();
+      expect(card.pendingWrite).toBe(true);
+      expect((component as any).cardsNeedRebuild()).toBe(false);
+
+      // Written after the debounce: the operator now holds it, so the two 
agree.
+      vi.advanceTimersByTime(FORM_DEBOUNCE_TIME_MS + 50);
+      vi.useRealTimers();
+      
expect(formBindingService.writeValue).toHaveBeenCalledWith(card.resolved.binding,
 "2500");
+      expect(card.pendingWrite).toBe(false);
+      formBindingService.readValue.mockReturnValue("2500");
+      expect((component as any).cardsNeedRebuild()).toBe(false);
+    });
+
+    it("writes a card's unwritten edit before a rebuild replaces the card, so 
the rebuild cannot lose it", () => {
+      build(formViewWorkflow).ngOnInit();
+      renderCard("n_hvg", "2000");
+      const card = component.rendered[0];
+      const key = card.resolved.binding.id;
+      vi.useFakeTimers();
+      card.model[key] = "2500";
+      card.form.addControl(key, new FormControl("2500"));
+      card.form.markAsDirty();
+      expect(formBindingService.writeValue).not.toHaveBeenCalled();
+
+      // A rebuild from any source (a step renamed, the definition edited) 
before the debounce fires.
+      (component as any).readConfig();
+      vi.advanceTimersByTime(FORM_DEBOUNCE_TIME_MS + 50);
+      vi.useRealTimers();
+
+      expect(formBindingService.writeValue).toHaveBeenCalledTimes(1);
+      
expect(formBindingService.writeValue).toHaveBeenCalledWith(card.resolved.binding,
 "2500");
+      expect(card.pendingWrite).toBe(false);
+    });
+
+    it("has no write to flush on a read-only viewer's card", () => {
+      build({ ...formViewWorkflow, readonly: true }).ngOnInit();
+      renderCard("n_hvg", "2000");
+      const card = component.rendered[0];
+      card.pendingWrite = true;
+
+      expect(card.flushWrite).toBeUndefined();
+      expect(() => (component as any).readConfig()).not.toThrow();
+      expect(formBindingService.writeValue).not.toHaveBeenCalled();
     });
 
     it("rebuilds the inputs when a step is renamed, so each card's attribution 
follows", () => {
@@ -1457,11 +1752,12 @@ describe("WorkflowFormComponent", () => {
 
     it("holds a rebuild while someone is typing and runs it once the focus 
leaves", async () => {
       build(formViewWorkflow).ngOnInit();
+      renderCard("n_hvg", "2000");
       const typing = vi.spyOn(component as any, 
"isTypingInTheForm").mockReturnValue(true);
       const rebuild = vi.spyOn(component as any, "readConfig");
+      changeSchema();
 
-      h.compilationChanged.next("Succeeded");
-      await new Promise(r => setTimeout(r, FORM_DEBOUNCE_TIME_MS + 50));
+      await compiled();
       expect(rebuild).not.toHaveBeenCalled();
 
       // The cursor leaves the field: the held rebuild runs, once. Held rather 
than dropped, or the
diff --git 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
index 30a780d0b9..39baf058f6 100644
--- 
a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
+++ 
b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts
@@ -97,8 +97,27 @@ interface RenderedField {
   fields: FormlyFieldConfig[];
   form: FormGroup;
   model: Record<string, unknown>;
+  /** The operator schema the card was built from, serialized: a compilation 
that leaves it as it
+   *  is has nothing to rebuild for this card. Empty for a broken card. */
+  schemaKey: string;
+  /** The operator's other properties the card was seeded with, serialized: 
the context a custom
+   *  widget reads to decide what to show (the HuggingFace picker reads 
`task`). Only a rebuild
+   *  re-seeds it, so a change there has to count as a change the card shows. 
The card's own
+   *  property is left out -- that one is compared on its own, and the card's 
pending edit must not
+   *  read as a change. Empty for a broken card. */
+  contextKey: string;
+  /** An edit the card holds that its debounced write-back has not written to 
the operator yet. */
+  pendingWrite: boolean;
+  /** Write the card's value to the operator now, if it differs (what the 
debounced write-back does);
+   *  absent on a read-only card. */
+  flushWrite?: () => void;
 }
 
+/** Whether two property values are the same, taking an empty string and an 
absent value as one (formly's
+ *  blank default for a property the operator never had). */
+const sameValue = (a: unknown, b: unknown): boolean =>
+  JSON.stringify(a === "" ? null : a ?? null) === JSON.stringify(b === "" ? 
null : b ?? null);
+
 /** One row of the "which results to show" picker: a candidate step and 
whether it is currently shown. */
 interface ResultChoice {
   operatorID: string;
@@ -479,15 +498,16 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
       });
 
     // Attribute boxes become dropdowns only after compilation writes the 
column enums into each
-    // operator's dynamic schema -- which lands after these cards were built. 
Rebuild on the
+    // operator's dynamic schema -- which lands after these cards were built. 
Follow the
     // compilation-state stream, a ReplaySubject(1) so a late subscriber (this 
page is created anew
-    // on every Canvas<->Form switch, mid-session) gets the current state at 
once. Held, not dropped, while
+    // on every Canvas<->Form switch, mid-session) gets the current state at 
once, and rebuild when
+    // it changed something the cards show (see onCompilationChanged). Held, 
not dropped, while
     // someone is typing (see rebuildFormOrDefer), so it neither throws away a 
half-entered value
     // under the cursor nor goes missing.
     this.workflowCompilingService
       .getCompilationStateInfoChangedStream()
       .pipe(debounceTime(FORM_DEBOUNCE_TIME_MS), untilDestroyed(this))
-      .subscribe(() => this.rebuildFormOrDefer(false));
+      .subscribe(() => this.onCompilationChanged());
 
     // Exposing or un-exposing a property in the panel changes the definition; 
the inputs above have
     // to follow at once, which is the whole point of editing them side by 
side. Today this fires for
@@ -838,24 +858,123 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
    * exposed property. Each input gets its own form keyed by binding id.
    */
   private buildForm(): void {
+    // A rebuild ends every card's write-back subscription, so an edit whose 
debounced write has not
+    // fired yet is written now, and the rebuilt card is seeded with it rather 
than without it.
+    this.rendered.forEach(card => card.pendingWrite && card.flushWrite?.());
     this.formsRebuilt.next();
     this.rendered = this.visibleFields
       .map(field => this.renderField(field))
       .filter((r): r is RenderedField => r !== undefined);
   }
 
+  /**
+   * A compilation result rebuilds the cards only when it changed something 
they show: a card's
+   * operator schema (the column enums compilation writes into it, which turn 
an attribute box into a
+   * dropdown), the set of inputs (an operator deleted on the canvas makes its 
card broken), or an
+   * operator value a card does not hold (a co-editor's edit; the card's own 
edit, written back or
+   * still on its way, is not that). Most compilations change none of these -- 
the one a card's own
+   * write triggers, for one -- and used to rebuild every card regardless: the 
controls were redrawn
+   * under the reader's eyes, the focus went with them, and a click made in 
the last moments before
+   * the rebuild was lost, since the rebuild cancelled that card's pending 
write. The canvas's
+   * property panel redraws only for a schema change; the form does the same. 
The result picker and
+   * the shown results still follow the graph either way.
+   */
+  private onCompilationChanged(): void {
+    if (this.cardsNeedRebuild()) {
+      this.rebuildFormOrDefer(false);
+      return;
+    }
+    this.refreshShownResults();
+    this.rebuildResultChoices();
+    this.cdr.markForCheck();
+  }
+
+  private cardsNeedRebuild(): boolean {
+    const fields = this.visibleOf(this.formBindingService.resolveFields());
+    const sameInputs =
+      fields.length === this.rendered.length &&
+      fields.every((field, i) => {
+        const shown = this.rendered[i].resolved;
+        return field.binding.id === shown.binding.id && (field.brokenReason ?? 
"") === (shown.brokenReason ?? "");
+      });
+    if (!sameInputs) {
+      return true;
+    }
+    return this.rendered.some(card => {
+      const { binding, brokenReason } = card.resolved;
+      if (brokenReason) {
+        return false;
+      }
+      if (this.schemaKeyFor(binding.operatorID) !== card.schemaKey) {
+        return true;
+      }
+      // The widget context the card was seeded with: checked before the 
pending-write skip below,
+      // which is only about the card's own property, and safe to rebuild 
through because buildForm
+      // flushes a pending write first.
+      if (this.contextKeyFor(binding.operatorID) !== card.contextKey) {
+        return true;
+      }
+      // The card's own edit is on its way to the operator; the two agree once 
it is written.
+      if (card.pendingWrite) {
+        return false;
+      }
+      return !sameValue(
+        card.model[binding.id],
+        this.formBindingService.readValue(binding.operatorID, 
binding.propertyKey)
+      );
+    });
+  }
+
+  private schemaKeyFor(operatorID: string): string {
+    return JSON.stringify(this.operatorSchemaFor(operatorID) ?? null);
+  }
+
+  /**
+   * The operator's properties other than the one a card is bound to, 
serialized with the keys
+   * sorted so a rewrite that only reorders them is not a change. This is the 
context the card is
+   * seeded with (see renderField): a widget reads a sibling to decide what to 
show, and nothing
+   * but a rebuild re-seeds it, so the card goes stale when the canvas or a 
co-editor changes one.
+   */
+  private contextKeyFor(operatorID: string): string {
+    const graph = this.workflowActionService.getTexeraGraph();
+    // getOperator throws for an operator that is gone, so it is only safe 
behind hasOperator.
+    const properties = graph.hasOperator(operatorID) ? 
graph.getOperator(operatorID)?.operatorProperties ?? {} : {};
+    // Every property an input is bound to is left out, this card's own and 
any other card's on the
+    // same operator: each is compared against the card that shows it, one by 
one. Counting them as
+    // context too would make one card's edit a change to every other card of 
that operator, and a
+    // form with two inputs from one step would rebuild wholesale on every 
keystroke's compilation --
+    // the churn this PR exists to stop (Copilot).
+    const bound = new Set(
+      this.parameters.filter(field => field.binding.operatorID === 
operatorID).map(field => field.binding.propertyKey)
+    );
+    return JSON.stringify(
+      Object.entries(properties)
+        .filter(([key]) => !bound.has(key))
+        .sort(([a], [b]) => a.localeCompare(b))
+    );
+  }
+
   private renderField(resolved: ResolvedField): RenderedField | undefined {
     const { binding } = resolved;
     // A broken input (its operator gone) has no schema to build a field from. 
Only an author ever
     // sees it (visibleFields drops it for readers), rendered as an empty card 
so the author can
     // remove it; a reader never reaches here for one.
     if (resolved.brokenReason) {
-      return { resolved, fields: [], form: new FormGroup({}), model: {} };
+      return {
+        resolved,
+        fields: [],
+        form: new FormGroup({}),
+        model: {},
+        schemaKey: "",
+        contextKey: "",
+        pendingWrite: false,
+      };
     }
     const schema = this.operatorSchemaFor(binding.operatorID);
     if (!schema) {
       return undefined;
     }
+    const schemaKey = JSON.stringify(schema);
     const operator = 
this.workflowActionService.getTexeraGraph().getOperator(binding.operatorID);
     const operatorType = operator?.operatorType;
     const full = this.formlyJsonschema.toFieldConfig(cloneDeep(schema) as 
never, {
@@ -903,30 +1022,47 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
       ...cloneDeep(operator?.operatorProperties ?? {}),
       [binding.id]: cloneDeep(resolved.value),
     };
+    const card: RenderedField = {
+      resolved,
+      fields: [field],
+      form,
+      model,
+      schemaKey,
+      contextKey: this.contextKeyFor(binding.operatorID),
+      pendingWrite: false,
+    };
     if (this.canEdit) {
+      card.flushWrite = () => {
+        card.pendingWrite = false;
+        // Formly emits the schema's empty default while building the control, 
before any edit;
+        // writing that back silently wiped the operator's real value (both 
views edit one
+        // workflow). So only accept a dirtied form, or a value that differs 
from the operator's
+        // without being emptier (some controls set values without marking 
dirty).
+        const next = model[binding.id];
+        const current = this.formBindingService.readValue(binding.operatorID, 
binding.propertyKey);
+        const isEmpty = (v: unknown) => v === undefined || v === null || v === 
"";
+        const unchanged = JSON.stringify(next ?? null) === 
JSON.stringify(current ?? null);
+        if (unchanged || (!form.dirty && isEmpty(next) && !isEmpty(current))) {
+          return;
+        }
+        // Write straight onto the operator (the same edit the canvas makes) 
and refresh this
+        // card's snapshot, which the template reads.
+        this.formBindingService.writeValue(binding, next);
+        this.parameters = this.formBindingService.resolveFields();
+        const refreshed = this.parameters.find(p => p.binding.id === 
binding.id);
+        if (refreshed) {
+          card.resolved = refreshed;
+        }
+      };
+      // The card holds an unwritten edit from the first change until the 
debounced write below has
+      // run (or a rebuild has flushed it, see buildForm).
+      form.valueChanges.pipe(takeUntil(this.formsRebuilt), 
untilDestroyed(this)).subscribe(() => {
+        card.pendingWrite = true;
+      });
       form.valueChanges
         .pipe(debounceTime(FORM_DEBOUNCE_TIME_MS), 
takeUntil(this.formsRebuilt), untilDestroyed(this))
         .subscribe(() => {
-          // Formly emits the schema's empty default while building the 
control, before any edit;
-          // writing that back silently wiped the operator's real value (both 
views edit one
-          // workflow). So only accept a dirtied form, or a value that differs 
from the operator's
-          // without being emptier (some controls set values without marking 
dirty).
-          const next = model[binding.id];
-          const current = 
this.formBindingService.readValue(binding.operatorID, binding.propertyKey);
-          const isEmpty = (v: unknown) => v === undefined || v === null || v 
=== "";
-          const unchanged = JSON.stringify(next ?? null) === 
JSON.stringify(current ?? null);
-          if (unchanged || (!form.dirty && isEmpty(next) && 
!isEmpty(current))) {
-            return;
-          }
-          // Write straight onto the operator (the same edit the canvas makes) 
and refresh this
-          // card's snapshot, which the template reads.
-          this.formBindingService.writeValue(binding, next);
-          this.parameters = this.formBindingService.resolveFields();
-          const refreshed = this.parameters.find(p => p.binding.id === 
binding.id);
-          const card = this.rendered.find(r => r.resolved.binding.id === 
binding.id);
-          if (refreshed && card) {
-            card.resolved = refreshed;
-          }
+          card.flushWrite?.();
           this.cdr.detectChanges();
         });
     } else {
@@ -939,7 +1075,7 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
     }
 
     this.applyFieldOverrides(field, binding, schemaLabel);
-    return { resolved, fields: [field], form, model };
+    return card;
   }
 
   /**
@@ -1159,9 +1295,13 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
    * (below), to repair or remove them.
    */
   public get visibleFields(): ResolvedField[] {
-    // A reader never sees a broken input (its operator is gone, so filling it 
could not affect the
-    // run); an author sees it, to repair or remove it.
-    return this.authoring ? this.parameters : this.parameters.filter(field => 
!field.brokenReason);
+    return this.visibleOf(this.parameters);
+  }
+
+  /** A reader never sees a broken input (its operator is gone, so filling it 
could not affect the run);
+   *  an author sees it, to repair or remove it. */
+  private visibleOf(fields: ResolvedField[]): ResolvedField[] {
+    return this.authoring ? fields : fields.filter(field => 
!field.brokenReason);
   }
 
   public trackByRendered(_: number, rendered: RenderedField): string {

Reply via email to