Repository: InseeFr/Lunatic
Pull request #1248: Delay onChange call on the next render
Author: login: Grafikart
Created: 2025-05-09T10:49:10Z | Merged: 2025-05-19T14:49:12Z

Call onChange when the new state of lunatic is compiled

Fix #1245

Linked issue #1245: [Lunatic] onChange devrait se déclencher après que le changement ait eu lieu
## Description du "problème"

Actuellement, le onChange renvoyé par "useLunatic" se déclenche alors que le dispatch interne à Lunatic n'a pas eu le temps d'avoir lieu, ce qui empêche d'utiliser la fonction correctement.

Il faudrait qu'elle soit appelée quand toutes les actions liées au dispatch sont bien finies. Cela aurait plus de sens et éviterait les problèmes quand on veut récupérer sur l'état de Lunatic suite au changement...

Note : Cela permettra d'améliorer le comportement de l'orchestrateur de Queen quant à la gestion des contrôles.

---

[Code Lunatic](https://github.com/InseeFr/Lunatic/blob/bcbf2aff86d296946fda9baed200ab80a4f7f069/src/use-lunatic/use-lunatic.ts#L172)

[Doc useReducer -> dispatch update issue](https://react.dev/reference/react/useReducer#ive-dispatched-an-action-but-logging-gives-me-the-old-state-value)

Solutions possible: mettre à dispo une autre fonction pour ce cas précis (callBack qui a besoin de l'état lunatic à jour)

## Critères d'acceptation

- le onChange est appelé après que le dispatch a fini sa tambouille
- le setTimeout est retiré du onChange dans l'orchestrateur de Queen et les contrôles fonctionnent toujours dans le cas de l'auto next :)

Diff:
diff --git a/src/stories/utils/Orchestrator.tsx b/src/stories/utils/Orchestrator.tsx
index 0dbd79c053..a6cd97539d 100644
--- a/src/stories/utils/Orchestrator.tsx
+++ b/src/stories/utils/Orchestrator.tsx
@@ -226,7 +226,7 @@ function OrchestratorForStories(props: Readonly<Props>) {
 						{tabs.map((t, k) => (
 							<button
 								tabIndex={-1}
-								key={JSON.stringify(t.label)}
+								key={k}
 								role="tab"
 								onClick={() => setTab(k)}
 								className={`tab gap-2 ${tab === k ? 'tab-active' : ''}`}
diff --git a/src/use-lunatic/hooks/useCallbackOnNextRender.test.tsx b/src/use-lunatic/hooks/useCallbackOnNextRender.test.tsx
new file mode 100644
index 0000000000..0f9468d129
--- /dev/null
+++ b/src/use-lunatic/hooks/useCallbackOnNextRender.test.tsx
@@ -0,0 +1,50 @@
+import { useState } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { useCallbackOnNextRender } from './useCallbackOnNextRender.js';
+import { act, fireEvent, render } from '@testing-library/react';
+
+describe('useCallbackOnNextRender', () => {
+	// Since we want to test when the callback is called, we need to keep track of the count state inside the component
+	let countSpy = 0;
+
+	const FakeComponent = (props: { onChange: () => void }) => {
+		const onChange = useCallbackOnNextRender(props.onChange);
+		const [count, setCount] = useState(0);
+		countSpy = count;
+
+		const onClick = () => {
+			setCount((v) => v + 1);
+			onChange();
+		};
+
+		return <button onClick={onClick}>{count}</button>;
+	};
+
+	it('should call the callback on the next render', () => {
+		let resolveSpy: (e?: unknown) => void;
+		const spyPromise = new Promise((resolve) => {
+			resolveSpy = resolve;
+		});
+		const onChange = vi.fn(() => {
+			try {
+				expect(countSpy).toBe(1);
+				resolveSpy();
+			} catch (e) {
+				resolveSpy(e);
+			}
+		});
+		const { getByRole } = render(<FakeComponent onChange={onChange} />);
+		act(() => {
+			fireEvent.click(getByRole('button'));
+		});
+
+		expect(onChange).toHaveBeenCalledTimes(1);
+
+		return spyPromise.then((r) => {
+			if (r instanceof Error) {
+				throw r;
+			}
+			expect(countSpy).toBe(1);
+		});
+	});
+});
diff --git a/src/use-lunatic/hooks/useCallbackOnNextRender.ts b/src/use-lunatic/hooks/useCallbackOnNextRender.ts
new file mode 100644
index 0000000000..6c22865b2c
--- /dev/null
+++ b/src/use-lunatic/hooks/useCallbackOnNextRender.ts
@@ -0,0 +1,31 @@
+import { useCallback, useEffect, useRef } from 'react';
+
+/**
+ * Run a callback on that will be run on the next render
+ */
+export function useCallbackOnNextRender<T extends (...args: unknown[]) => void>(
+	cb: T
+): T {
+	const callOnNextRender = useRef<Parameters<T> | null>(null);
+	const cbRef = useRef(cb);
+	cbRef.current = cb;
+
+	// use useEffect to run the callback at the end of the next render
+	useEffect(() => {
+		if (callOnNextRender.current === null) {
+			return;
+		}
+		try {
+			cbRef.current(...callOnNextRender.current);
+		} catch (e) {
+			console.error(e);
+		}
+		callOnNextRender.current = null;
+		// eslint-disable-next-line react-hooks/exhaustive-deps -- We want to run the callback when the value of the ref changed on the next render
+	}, [callOnNextRender.current]);
+
+	// When the function is called, we store the arguments to be called on the next render (when state was really updated)
+	return useCallback((...args: Parameters<T>) => {
+		callOnNextRender.current = args;
+	}, []) as T;
+}
diff --git a/src/use-lunatic/use-lunatic.ts b/src/use-lunatic/use-lunatic.ts
index 27d777779f..59910427ca 100644
--- a/src/use-lunatic/use-lunatic.ts
+++ b/src/use-lunatic/use-lunatic.ts
@@ -32,6 +32,7 @@ import { mergeDefault } from '../utils/object';
 import { useRefSync } from '../hooks/useRefSync';
 import { ConsoleLogger } from './logger/ConsoleLogger';
 import { useWarnDepChange } from './hooks/useWarnDepChange';
+import { useCallbackOnNextRender } from './hooks/useCallbackOnNextRender';
 
 const empty = {}; // Keep the same empty object (to avoid problem with useEffect dependencies)
 const DEFAULT_DATA = empty as LunaticData;
@@ -169,12 +170,14 @@ export function useLunatic(
 		[dispatch]
 	);
 
+	const onChangeAfterRender = useCallbackOnNextRender(onChange);
+
 	const handleChanges = useCallback<LunaticChangesHandler>(
 		(responses) => {
 			dispatch(handleChangesAction(responses));
-			onChange(responses);
+			onChangeAfterRender(responses);
 		},
-		[dispatch, onChange]
+		[dispatch, onChangeAfterRender]
 	);
 
 	const getData: LunaticState['getData'] = (


Reviews:
user: login: QRuhier
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T09:07:17Z
user: login: Grafikart
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-13T16:25:41Z
user: login: QRuhier
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T11:31:53Z
user: login: laurentC35
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T13:53:11Z
user: login: chloe-renaud
body: Tested on Drama Queen where we had an issue with it, it works great!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T14:35:54Z

Review comments:
diff_hunk: @@ -226,7 +226,7 @@ function OrchestratorForStories(props: Readonly<Props>) {
 						{tabs.map((t, k) => (
 							<button
 								tabIndex={-1}
-								key={JSON.stringify(t.label)}
+								key={k}
path: src/stories/utils/Orchestrator.tsx
user: login: QRuhier
body: @Grafikart Why this change ?
Especially using the index as key not being a good practical
created_at: 2025-05-12T09:07:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -226,7 +226,7 @@ function OrchestratorForStories(props: Readonly<Props>) {
 						{tabs.map((t, k) => (
 							<button
 								tabIndex={-1}
-								key={JSON.stringify(t.label)}
+								key={k}
path: src/stories/utils/Orchestrator.tsx
user: login: Grafikart
body: The array does not change and the label can be an object (this cause an issue on the story "Text" for instance with circle dependency)
created_at: 2025-05-13T16:25:41Z
author_association: COLLABORATOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-19T14:45:01Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=InseeFr_Lunatic&pullRequest=1248) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=InseeFr_Lunatic&pullRequest=1248&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=InseeFr_Lunatic&pullRequest=1248&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=InseeFr_Lunatic&pullRequest=1248&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [51.6% Coverage on New Code](https://sonarcloud.io/component_measures?id=InseeFr_Lunatic&pullRequest=1248&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=InseeFr_Lunatic&pullRequest=1248&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=InseeFr_Lunatic&pullRequest=1248)


author_association: NONE