Skip to content

fix: resync tracked properties editor state#9387

Open
jibin7jose wants to merge 13 commits into
Azure:mainfrom
jibin7jose:fix/tracked-properties-sync
Open

fix: resync tracked properties editor state#9387
jibin7jose wants to merge 13 commits into
Azure:mainfrom
jibin7jose:fix/tracked-properties-sync

Conversation

@jibin7jose

@jibin7jose jibin7jose commented Jul 12, 2026

Copy link
Copy Markdown

fix: resync tracked properties editor state

Commit Type

  • fix - Bug fix

Risk Level

  • Low - Minor changes, limited scope

What & Why

  • This PR fixes a tracked-properties editor desynchronization issue in the new portal view.
  • The dictionary editor was holding its own local copy of the tracked-properties values, which could become stale if the parent value was refreshed.
  • trackedProperties is now cloned when loaded into settings to reduce the chance of shared-reference mutation.
  • The dictionary editor now resyncs when its incoming value prop changes, so the UI stays aligned with the latest workflow state.
  • A regression test was added to verify rerender behavior when the incoming dictionary value changes.

Impact of Change

  • Users: tracked properties should no longer appear to drift or save from a stale UI copy after workflow edits.
  • Developers: the editor is safer against prop/state mismatch and shared-reference side effects.
  • System: no backend behavior change was intended; this is a UI state-sync hardening fix.

Test Plan

  • Unit tests added/updated: yes
  • E2E tests added/updated: no
  • Manual testing completed: attempted locally, but vitest is not available in this environment through the package manager setup
  • Tested in: local workspace review and code inspection

Contributors

  • @jibin7jose

Screenshots/Videos

  • Not included

Copilot AI review requested due to automatic review settings July 12, 2026 17:23
@github-actions

Copy link
Copy Markdown
Contributor

🔒 AI Validation Pending

This PR is from an external contributor. AI validation will be performed after manual review by a maintainer.

Maintainers: Add the external-approved label to this PR after reviewing the changes to enable AI validation.

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.

Pull request overview

This PR aims to fix “tracked properties” editor desynchronization by ensuring tracked properties are not passed around by reference and by keeping the dictionary editor UI state aligned with refreshed parent values.

Changes:

  • Deep-clone trackedProperties when extracting settings from the action definition to avoid shared-reference mutations.
  • Add prop→state synchronization to SimpleDictionary so the editor reflects refreshed incoming values.
  • Add a unit test covering rerendering with a changed incoming dictionary value.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
libs/designer-v2/src/lib/core/actions/bjsworkflow/settings.ts Clone trackedProperties before returning it as a setting value to prevent shared-reference state issues.
libs/designer-ui/src/lib/settings/settingsection/dictionary/simpledictionary.tsx Add a useEffect to resync internal editor rows when the value prop changes.
libs/designer-ui/src/lib/settings/settingsection/tests/simpledictionary.spec.tsx New test to verify the dictionary editor updates when the incoming value changes on rerender.
Comments suppressed due to low confidence (1)

libs/designer-ui/src/lib/settings/settingsection/dictionary/simpledictionary.tsx:55

  • The new useEffect(() => setValues(createValues(value)), [value]) turns SimpleDictionary into a controlled-ish component, but the existing useEffect that calls onChange on every values update will now create a feedback loop for the common pattern value={state} onChange={setState} (e.g. payloadPopover.tsx uses onChange={setHeadersValue}), potentially causing repeated re-renders / "Maximum update depth exceeded". To avoid this, only emit onChange when the derived dictionary actually differs from the incoming value (and include value/onChange in deps so the eslint suppression can be removed).
  // Keep the editor in sync if the tracked-properties value is refreshed from the parent.
  useEffect(() => {
    setValues(createValues(value));
  }, [value]);

  useEffect(() => {
    onChange?.(
      values
        .filter((x) => x.key && x.key !== '')
        .reduce((acc: any, val) => {
          acc[val.key] = val.value;
          return acc;
        }, {})
    );
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [values]);

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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment on lines 861 to 865
const getTrackedProperties = (isTrigger: boolean, manifest?: OperationManifest, definition?: LogicAppsV2.ActionDefinition): any => {
const supported = areTrackedPropertiesSupported(isTrigger, manifest);
return supported && definition ? getPropertyValue(definition as any, 'trackedProperties') : undefined;
const trackedProperties = supported && definition ? getPropertyValue(definition as any, 'trackedProperties') : undefined;
return trackedProperties ? clone(trackedProperties) : trackedProperties;
};
Comment on lines +40 to +44
// Keep the editor in sync if the tracked-properties value is refreshed from the parent.
useEffect(() => {
setValues(createValues(value));
}, [value]);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed. I’ve changed SimpleDictionary to resync local state only when the incoming prop value actually differs, and onChange now only fires when the local dictionary differs from the current prop value. That prevents redundant dispatches and avoids the prop/state feedback loop.

@jibin7jose

Copy link
Copy Markdown
Author

The PR looks blocked only by the AI validation approval gate. The workflow in .github/workflows/pr-ai-validation.yml stops external contributors until a maintainer adds the external-approved label. Once that label is added, the validation job should proceed.

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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

import { useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import { useStyles } from './simpledictionary.styles';
import { equals } from '@microsoft/logic-apps-shared';

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. I’ve updated getTrackedProperties to only clone plain objects, so primitive trackedProperties values now pass through unchanged and won’t be converted into an object shape.

Comment on lines +54 to +58
useEffect(() => {
const nextValues = createValues(value);
if (!equals(nextValues, valuesRef.current)) {
setValues(nextValues);
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. I replaced the comparison with deepCompareObjects and compare the normalized dictionary value instead of the row array. That keeps the prop-sync behavior without runtime errors or redundant onChange updates.

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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment on lines +52 to +58
// Keep the editor in sync if the tracked-properties value is refreshed from the parent.
useEffect(() => {
const nextValues = createValues(value);
if (!deepCompareObjects(valuesToDictionary(nextValues), value)) {
setValues(nextValues);
}
}, [value]);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. I’ve hoisted the helper functions out of the component, so the effect no longer depends on unstable local function identities and react-hooks/exhaustive-deps should stay clean.

Comment on lines 60 to +64
useEffect(() => {
onChange?.(
values
.filter((x) => x.key && x.key !== '')
.reduce((acc: any, val) => {
acc[val.key] = val.value;
return acc;
}, {})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [values]);
const nextDictionary = valuesToDictionary(values);
if (!deepCompareObjects(nextDictionary, value)) {
onChange?.(nextDictionary);
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. I’ve also hoisted valuesToDictionary out of the component, so this effect now uses stable helpers and won’t trigger the dependency warning.

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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment on lines +52 to +56
// Keep the editor in sync if the tracked-properties value is refreshed from the parent.
useEffect(() => {
const nextValues = createValues(value);
if (!deepCompareObjects(valuesToDictionary(nextValues), value)) {
setValues(nextValues);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. I updated the effect flow so prop-driven updates only resync local state, while onChange is skipped during the initial render and parent syncs. That prevents the stale emit/feedback loop and keeps the editor aligned with incoming values.

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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment on lines 55 to +59
useEffect(() => {
onChange?.(
values
.filter((x) => x.key && x.key !== '')
.reduce((acc: any, val) => {
acc[val.key] = val.value;
return acc;
}, {})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const nextValues = createValues(value);
if (!deepCompareObjects(valuesToDictionary(valuesRef.current), value)) {
isSyncingFromParentRef.current = true;
setValues(nextValues);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. I normalized empty dictionaries to undefined before comparing, so {} and an empty editor state are treated the same and we avoid unnecessary resyncs.

Comment on lines +864 to +865
const trackedProperties = supported && definition ? getPropertyValue(definition as any, 'trackedProperties') : undefined;
return isObject(trackedProperties) ? clone(trackedProperties) : trackedProperties;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. I hardened the tracked-properties copy path to skip prototype-pollution keys like proto, constructor, and prototype before cloning recursively.

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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment on lines +30 to +35
const nextDictionary = dictionaryRows.reduce((acc, row) => {
if (row.key) {
acc[row.key] = row.value;
}
return acc;
}, {} as Record<string, string>);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. I’ll harden valuesToDictionary the same way as the clone path by skipping proto, constructor, and prototype when building the emitted dictionary, so user-entered keys can’t pollute the accumulator object.

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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment on lines +61 to +63
const nextValues = createValues(value);
const currentDictionary = normalizeDictionary(valuesToDictionary(valuesRef.current));
const nextDictionary = normalizeDictionary(value);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. I’ve normalized the incoming value through the same dictionary conversion path before comparing, so both sides use the same filtering rules and we avoid unnecessary resyncs on ignored keys or blank entries.

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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment on lines +867 to 886
const cloneTrackedProperties = (trackedProperties: Record<string, any>): Record<string, any> => {
const safeClone: Record<string, any> = {};

for (const key of Object.keys(trackedProperties)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}

const value = trackedProperties[key];
if (Array.isArray(value)) {
safeClone[key] = value.map((item) => (isObject(item) ? cloneTrackedProperties(item) : item));
} else if (isObject(value)) {
safeClone[key] = cloneTrackedProperties(value);
} else {
safeClone[key] = value;
}
}

return safeClone;
};

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. I’ll make the array clone recursive so nested arrays are copied by value too, not just plain-object items. That keeps trackedProperties fully detached from the source definition and avoids shared mutable sub-structures.

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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@jibin7jose

Copy link
Copy Markdown
Author

Copilot review is now clean and there are no new comments. Could you please add the external-approved label or otherwise approve the PR so the AI validation workflow can proceed?

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.

2 participants