Skip to content

[copilot] refactor OptimizelyPageMetrics notification listener to use…#13893

Draft
HarveyPeachey wants to merge 1 commit intoWS-2284-fix-lost-view-events-in-optimizelyfrom
WS-2284-fix-lost-view-events-in-optimizely-alt
Draft

[copilot] refactor OptimizelyPageMetrics notification listener to use…#13893
HarveyPeachey wants to merge 1 commit intoWS-2284-fix-lost-view-events-in-optimizelyfrom
WS-2284-fix-lost-view-events-in-optimizely-alt

Conversation

@HarveyPeachey
Copy link
Copy Markdown
Contributor

… useSyncExternalStore

Resolves JIRA:

Summary

A very high-level summary of easily-reproducible changes that can be understood by non-devs, and why these changes where made.

Code changes

  • List key code changes that have been made.

Testing

  1. List the steps required to test this PR.

Useful Links

const [isInExperiment, setIsInExperiment] = useState(false);
const activatedExperiments = useActivatedExperiments();

const experimentsForPageType = experimentsForPageMetrics.find(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think we can actually remove the reliance on the maintained list, now that we have an external store that informs us which experiments have been activated

@HarveyPeachey HarveyPeachey marked this pull request as ready for review April 9, 2026 14:19
Copilot AI review requested due to automatic review settings April 9, 2026 14:19
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Refactors Optimizely Page Metrics experiment-gating to use a shared decision store backed by useSyncExternalStore, with decisions captured via a central Optimizely notification listener.

Changes:

  • Added optimizelyDecisionStore (subscribe/snapshot/notify + hook) to track activated experiments.
  • Moved DECISION notification handling to withOptimizelyProvider and forwards activations into the store.
  • Updated OptimizelyPageMetrics and its tests to rely on the store rather than local effects/listeners.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/app/lib/optimizelyDecisionStore.ts New external store + useSyncExternalStore hook for activated experiments.
src/app/lib/optimizelyDecisionStore.test.ts Unit tests covering store snapshot, subscriptions, and reset.
src/app/legacy/containers/PageHandlers/withOptimizelyProvider/index.tsx Adds a global DECISION notification listener that calls notifyDecision.
src/app/components/OptimizelyPageMetrics/index.tsx Switches gating logic to store-driven activated experiments; removes local listener/decide logic.
src/app/components/OptimizelyPageMetrics/index.test.tsx Updates tests to drive behaviour via decision store notifications.

Comment on lines +4 to +17
let snapshot: ReadonlySet<string> = new Set<string>();
const subscribers = new Set<() => void>();

const subscribe = (callback: () => void) => {
subscribers.add(callback);
return () => {
subscribers.delete(callback);
};
};

const getSnapshot = (): ReadonlySet<string> => snapshot;

const notifyDecision = (flagKey: string) => {
if (activatedExperiments.has(flagKey)) return;
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

This store is a module-level singleton (activatedExperiments/snapshot/subscribers). Because withOptimizelyProvider sets isServerSide and this code can be evaluated during SSR, state can leak between server requests/users if notifyDecision is ever called on the server process. Consider making updates client-only (e.g., guard notifyDecision/listener with typeof window !== 'undefined') and/or scoping the store per request rather than per module.

Suggested change
let snapshot: ReadonlySet<string> = new Set<string>();
const subscribers = new Set<() => void>();
const subscribe = (callback: () => void) => {
subscribers.add(callback);
return () => {
subscribers.delete(callback);
};
};
const getSnapshot = (): ReadonlySet<string> => snapshot;
const notifyDecision = (flagKey: string) => {
if (activatedExperiments.has(flagKey)) return;
const emptySnapshot: ReadonlySet<string> = new Set<string>();
let snapshot: ReadonlySet<string> = new Set<string>();
const subscribers = new Set<() => void>();
const isBrowser = typeof window !== 'undefined';
const subscribe = (callback: () => void) => {
if (!isBrowser) {
return () => {};
}
subscribers.add(callback);
return () => {
subscribers.delete(callback);
};
};
const getSnapshot = (): ReadonlySet<string> =>
isBrowser ? snapshot : emptySnapshot;
const notifyDecision = (flagKey: string) => {
if (!isBrowser || activatedExperiments.has(flagKey)) return;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

In our setup I don't think this would be the case currently, as decisions that we make using the SDK that send events to the optimizely metrics endpoint are carried out client side. But it's a good point, I guess do we wanna be YAGNI with this?

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.

Imo with this sort of thing we should be aware of the context it runs in and not bloat it with defensive code as it makes it less readable

Comment on lines +16 to +25
const notifyDecision = (flagKey: string) => {
if (activatedExperiments.has(flagKey)) return;
activatedExperiments.add(flagKey);
snapshot = new Set(activatedExperiments);
subscribers.forEach(cb => cb());
};

const resetDecisionStore = () => {
activatedExperiments.clear();
snapshot = new Set<string>();
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

resetDecisionStore clears internal state but doesn’t notify current subscribers. Components using useActivatedExperiments() would keep rendering with a stale snapshot until another decision is notified. If resetDecisionStore is intended as a real reset (not test-only), it should update snapshot and trigger subscriber callbacks (or otherwise document/rename it to clarify test-only usage).

Suggested change
const notifyDecision = (flagKey: string) => {
if (activatedExperiments.has(flagKey)) return;
activatedExperiments.add(flagKey);
snapshot = new Set(activatedExperiments);
subscribers.forEach(cb => cb());
};
const resetDecisionStore = () => {
activatedExperiments.clear();
snapshot = new Set<string>();
const notifySubscribers = () => {
subscribers.forEach(callback => callback());
};
const notifyDecision = (flagKey: string) => {
if (activatedExperiments.has(flagKey)) return;
activatedExperiments.add(flagKey);
snapshot = new Set(activatedExperiments);
notifySubscribers();
};
const resetDecisionStore = () => {
activatedExperiments.clear();
snapshot = new Set<string>();
notifySubscribers();

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

In our case I don't think this really matters

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.

Do we even need the reset functionality in our case?

Comment on lines +39 to +55
optimizely.notificationCenter.addNotificationListener(
enums.NOTIFICATION_TYPES.DECISION,
(
notification: ListenerPayload & {
decisionInfo: {
flagKey: string;
variationKey: string;
decisionEventDispatched: boolean;
};
},
) => {
const { flagKey, variationKey, decisionEventDispatched } =
notification.decisionInfo;

if (decisionEventDispatched && variationKey !== 'off') {
notifyDecision(flagKey);
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

The decision notification handler assumes notification.decisionInfo is always present and destructures it unconditionally. If Optimizely emits other DECISION payload shapes (or decisionInfo is absent), this will throw at runtime and break page rendering. Please add runtime guards (e.g., verify notification?.type/notification?.decisionInfo exists) before reading flagKey/variationKey.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah good point, better to use optional chaining for this just in case, although highly unlikely to happen

Comment on lines 22 to +37
@@ -45,116 +30,16 @@ const OptimizelyPageMetrics = ({
experimentsForPageType?.length && !isAmp,
);

// on initial load, check if the user is in any relevant experiment and set state accordingly
useEffect(() => {
if (
!optimizelyExperimentsEnabled ||
!optimizely ||
!experimentsForPageType
) {
setIsInExperiment(false);
return undefined;
}

let mounted = true;

optimizely.onReady().then(() => {
if (!mounted) return;

// disable decision event tracking to avoid sending duplicate events for any experiments that the user is bucketed into on page load, since the notification listener will also trigger for those experiments
const decisions = optimizely.decideAll([
OptimizelyDecideOption.DISABLE_DECISION_EVENT,
]);

const userInAnyExperiment = experimentsForPageType.some(
experimentName => {
const decision = decisions[experimentName];
return Boolean(decision && decision.variationKey !== 'off');
},
);

setIsInExperiment(userInAnyExperiment);
});

return () => {
mounted = false;
};
}, [optimizelyExperimentsEnabled, optimizely, experimentsForPageType]);

// Listen for Optimizely decisions after initial load in case the user is bucketed later
useEffect(() => {
if (
!optimizelyExperimentsEnabled ||
!optimizely ||
!experimentsForPageType
) {
setIsInExperiment(false);
return undefined;
}

let mounted = true;
let notificationId: number | null = null;
const isInExperiment =
optimizelyExperimentsEnabled &&
Boolean(
experimentsForPageType?.some(name => activatedExperiments.has(name)),
);
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

This refactor removes the initial decideAll(...DISABLE_DECISION_EVENT) check that previously detected experiment membership on first load. isInExperiment now depends entirely on activatedExperiments being populated by a prior decision notification; if no other code evaluates these flags, the tracking components will never mount for already-bucketed users. If the intent is to preserve prior behaviour, consider seeding the store (or local state) by evaluating the relevant flags once on mount.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think we need to preserve prior behaviour as decisions will always be made on a page load via useOptimizelyVariation I think

@HarveyPeachey HarveyPeachey marked this pull request as draft April 9, 2026 14:26
expect(
screen.queryByTestId('page-complete-tracking'),
).not.toBeInTheDocument();
it('should return null when experiment names do not match activated experiments', () => {
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.

Suggested change
it('should return null when experiment names do not match activated experiments', () => {
it('should not include tracking when experiment names do not match activated experiments', () => {

Other test descriptions seem a bit unclear, this is just one example

Comment on lines +7 to +12
const subscribe = (callback: () => void) => {
subscribers.add(callback);
return () => {
subscribers.delete(callback);
};
};
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.

I find this subscription API very unintuitive, is this a common approach?

Comment on lines +4 to +17
let snapshot: ReadonlySet<string> = new Set<string>();
const subscribers = new Set<() => void>();

const subscribe = (callback: () => void) => {
subscribers.add(callback);
return () => {
subscribers.delete(callback);
};
};

const getSnapshot = (): ReadonlySet<string> => snapshot;

const notifyDecision = (flagKey: string) => {
if (activatedExperiments.has(flagKey)) return;
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.

Imo with this sort of thing we should be aware of the context it runs in and not bloat it with defensive code as it makes it less readable

Comment on lines +16 to +25
const notifyDecision = (flagKey: string) => {
if (activatedExperiments.has(flagKey)) return;
activatedExperiments.add(flagKey);
snapshot = new Set(activatedExperiments);
subscribers.forEach(cb => cb());
};

const resetDecisionStore = () => {
activatedExperiments.clear();
snapshot = new Set<string>();
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.

Do we even need the reset functionality in our case?

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.

3 participants