Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions src/components/Common/PluginErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import React from "react";

interface PluginErrorBoundaryProps {
children: React.ReactNode;
pluginName: string;
fallback?: React.ReactNode;
/** Notified once the boundary has caught. Callers that must react to the
* failure elsewhere in the app use it — the questionnaire fill page
* records the question so submit-time validation stops requiring an
* input that is no longer on screen. */
onError?: (error: Error) => void;
}

export class PluginErrorBoundary extends React.Component<
{ children: React.ReactNode; pluginName: string; fallback?: React.ReactNode },
PluginErrorBoundaryProps,
{ hasError: boolean }
> {
constructor(props: {
children: React.ReactNode;
pluginName: string;
fallback?: React.ReactNode;
}) {
constructor(props: PluginErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
Expand All @@ -23,6 +30,7 @@ export class PluginErrorBoundary extends React.Component<
error,
errorInfo,
);
this.props.onError?.(error);
}

render() {
Expand Down
26 changes: 21 additions & 5 deletions src/components/QuestionnaireV2/fill/submit/composeBatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,13 @@ export interface ComposeBatchArgs {
questionnaire: QuestionnaireRead;
responses: Record<string, QuestionnaireResponse>;
subject: FillSubject;
/** Question ids whose structured slot threw and shows the error
* boundary's notice (`structuredRenderFailedAtom`). The validators skip
* these on the premise that their data never submits — this is the
* compose half of that bargain. Without it, rows recorded BEFORE the
* component broke would post to the domain APIs with their type's
* `validate` never run, from a section the UI presents as inert. */
renderFailed?: ReadonlySet<string>;
/** Resuming a server draft — appends the completion PUT. */
continueDraftId?: string;
}
Expand Down Expand Up @@ -104,6 +111,7 @@ export async function composeBatch({
questionnaire,
responses,
subject,
renderFailed,
continueDraftId,
}: ComposeBatchArgs): Promise<StructuredBatchEntry[]> {
// Narrowed once, up front: the structured leg and the draft PUT both
Expand All @@ -129,6 +137,11 @@ export async function composeBatch({
if (!response) continue;

if (question.type === "structured" && question.structured_type) {
// The slot's component threw — the clinician sees a notice, not
// their data, and validateStructured skipped this question's
// `validate` for the same reason. What the UI shows as inert must
// not submit behind its back.
if (renderFailed?.has(question.id)) continue;
// The recorded entries must belong to this question's type — the
// guard `structuredDataOf` used to carry, kept now that the data
// read is untyped.
Expand Down Expand Up @@ -165,11 +178,14 @@ export async function composeBatch({
continue;
}

if (
response.values.length > 0 &&
response.values[0]?.value !== "" &&
!response.structured_type
) {
// Every plain leaf goes through serialization; the content decision
// is `serializeResponseValues`' alone (its filter keeps value-,
// coding- and unit-carrying entries). Gating here on values[0]
// dropped a repeats answer wholesale when its FIRST row was cleared
// in place — later rows silently never submitted, while the
// required check (which scans every entry) reported the question
// answered.
if (!response.structured_type) {
answeredLeaves.push(response);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

if (!response.structured_type) {
  answeredLeaves.push(response);
}

The old code checked response.values.length > 0 && response.values[0]?.value !== "". Now you're pushing responses with an EMPTY values array to answeredLeaves. Yes, you say serializeResponseValues handles it — but that means you're now calling serialize on every leaf response regardless, including ones with no data at all. Whether that's a silent no-op or eventually becomes a backend validation error depends entirely on what serializeResponseValues does with an empty array and whether the API rejects empty leaf objects in the batch.

This fix is correct for the multi-row repeats bug, but dropping the values.length > 0 guard entirely is broader than the fix requires. At minimum you should be checking response.values.length > 0 still, and letting serializeResponseValues decide content; the original bug was gating on values[0] not on length.

}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The safety of this removal depends entirely on the .filter((result) => result.values.length > 0) 20-odd lines below. The comment here says "the content decision is serializeResponseValues' alone" but doesn't mention the filter is the actual guard against submitting values: [] to the server (which your own comment below calls "a server error rather than an omission"). These two comments should reference each other — right now the reader has to connect the dots across two separate code blocks to verify correctness.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useMutation } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";

import {
errorsAtom,
responsesAtom,
structuredRenderFailedAtom,
} from "@/components/QuestionnaireV2/form/engine/store";
import { collectRequiredErrors } from "@/components/QuestionnaireV2/form/validation";

Expand Down Expand Up @@ -52,7 +53,12 @@ function scrollToQuestion(questionId: string) {
`[data-question-id="${questionId}"]`,
);
if (!block) return;
block.scrollIntoView({ block: "center", behavior: "smooth" });
block.scrollIntoView({
block: "center",
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
? "auto"
: "smooth",
});
const input = document.getElementById(`question-input-${questionId}`);
if (input) {
input.focus({ preventScroll: true });
Expand Down Expand Up @@ -148,35 +154,53 @@ export function useSubmitFillSession({
},
});

const submit = useCallback(async () => {
const runSubmit = useCallback(async () => {
// 1) Validate every form against its own store; the first failure
// anywhere in the session decides where we scroll. The renderer's
// flat subject view goes in so the required check can tell a
// structured question that HAS an input from one showing a
// placeholder (see form/validation.ts).
// flat subject view and the form's render-failed set go in so the
// required check can tell a structured question that HAS an input
// from one showing a notice (see form/validation.ts).
const rendererSubject = rendererSubjectOf(subject);
let firstError: { formKey: string; questionId: string } | undefined;
for (const form of forms) {
const store = getStore(form.key);
if (!store) continue;
const responses = store.get(responsesAtom);
const renderFailed = store.get(structuredRenderFailedAtom);
const clientErrors: QuestionValidationError[] = [
...collectRequiredErrors(form.questionnaire.questions, responses, t, {
questionnaire: form.questionnaire,
subject: rendererSubject,
renderFailed,
}),
...collectStructuredErrors(
form.questionnaire,
responses,
rendererSubject,
renderFailed,
t,
),
];
store.set(errorsAtom, clientErrors);
if (clientErrors.length > 0 && !firstError) {
// "First" in the clinician's reading order, not in validator
// order — the array concatenates every required failure before
// any structured one, so its head can sit far below an earlier
// failing question on screen.
const failing = new Set(clientErrors.map((error) => error.question_id));
const inTreeOrder = (questions: Question[]): string | undefined => {
for (const question of questions) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

const inTreeOrder = (questions: Question[]): string | undefined => {
  for (const question of questions) {
    if (failing.has(question.id)) return question.id;
    const found = inTreeOrder(question.questions ?? []);
    if (found) return found;
  }
  return undefined;
};

This recursive helper is defined INSIDE useCallback, which means it's redefined every time runSubmit is called (which is fine — it's transient). But it's also closing over failing which is itself constructed fresh each call. No correctness bug here, but inTreeOrder could trivially be a module-level utility taking questions and failing as args — it has no need to be a closure. As-is it's a bit hard to test in isolation.

Also: this walks the tree for EVERY form's errors, but you only need it when clientErrors.length > 0. Nit, since forms are small, but still.

if (failing.has(question.id)) return question.id;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

inTreeOrder is a recursive closure redeclared inside runSubmit on every submit-with-errors call. For large questionnaires with many nested groups, this walks the full tree once per form that fails. It's not a hot path so it won't crater performance, but this function belongs outside the callback — it takes only questions and failing, has no other dependencies on the closure, and would be cleaner as a module-level helper.

const found = inTreeOrder(question.questions ?? []);
if (found) return found;
}
return undefined;
};
firstError = {
formKey: form.key,
questionId: clientErrors[0].question_id,
questionId:
inTreeOrder(form.questionnaire.questions) ??
clientErrors[0].question_id,
};
}
}
Expand Down Expand Up @@ -205,6 +229,7 @@ export function useSubmitFillSession({
questionnaire: form.questionnaire,
responses: store.get(responsesAtom),
subject,
renderFailed: store.get(structuredRenderFailedAtom),
continueDraftId: form.isPrimary ? continueDraftId : undefined,
});
}),
Expand Down Expand Up @@ -240,5 +265,39 @@ export function useSubmitFillSession({
submitBatch({ requests });
}, [forms, getStore, subject, continueDraftId, submitBatch, t]);

return { submit, isPending, serverErrors };
// Compose runs BEFORE the mutation starts, and structured
// `buildRequests` are async — during that window the mutation's
// isPending is still false, so a second click would validate and
// compose a second identical batch. The ref closes the window
// synchronously (state alone leaves the same-tick gap); the state twin
// keeps the button disabled for the same span.
const composingRef = useRef(false);
const [isComposing, setIsComposing] = useState(false);

/**
* The one entry point, and the outermost containment boundary. The page
* fires this as `onSubmit={() => void submit()}`, so ANY escaping
* rejection — a plugin component's getter, a malformed store record, a
* future call added inside `runSubmit` — would become an unhandled
* promise rejection and turn Save Changes into a silent no-op. Failing
* loudly is the floor: the clinician always learns the submission did
* not happen, and the original error still reaches the console for the
* developer.
*/
const submit = useCallback(async () => {
if (composingRef.current || isPending) return;
composingRef.current = true;
setIsComposing(true);
try {
await runSubmit();
} catch (error) {
console.error("Questionnaire submission failed unexpectedly", error);
toast.error(t("questionnaire_submission_failed"));
} finally {
composingRef.current = false;
setIsComposing(false);
}
}, [runSubmit, isPending, t]);

return { submit, isPending: isPending || isComposing, serverErrors };
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ import type { QuestionnaireRead } from "@/types/questionnaire/questionnaire";
* ones alike). Same disabled-subtree skip as composeBatch/validation.ts.
*
* A question whose slot cannot show an input is skipped — subject
* mismatch, or a mount that can't supply an id the type `requires`. The
* clinician has no way to answer those, and composeBatch drops their data
* regardless, so validating them would only block submission on data that
* never submits. The predicate is the one `StructuredSlot` renders from;
* see `StructuredSlotState`'s parity note.
* mismatch, a mount that can't supply an id the type `requires`, or a
* component that threw and left the error boundary's notice in its place.
* The clinician has no way to answer any of those, and composeBatch drops
* their data regardless, so validating them would only block submission on
* data that never submits. Shares one predicate with `collectRequiredErrors`
* — see `structuredQuestionIsAnswerable`'s parity note.
*
* A type this deployment doesn't have blocks the submit only when the
* question is required: an optional question whose plugin is disabled is
Expand All @@ -38,6 +39,7 @@ export function collectStructuredErrors(
questionnaire: QuestionnaireRead,
responses: Record<string, QuestionnaireResponse>,
subject: RendererSubject,
renderFailed: ReadonlySet<string>,
t: TFunction,
): QuestionValidationError[] {
const linkIndex = buildLinkIndex(questionnaire.questions);
Expand All @@ -54,6 +56,9 @@ export function collectStructuredErrors(
continue;
}
const type = question.structured_type;
// The slot's component threw — the notice is on screen, not an
// input, so there is nothing here to validate.
if (renderFailed.has(question.id)) continue;
const state = resolveStructuredSlotState(
type,
questionnaire.subject_type,
Expand Down
28 changes: 27 additions & 1 deletion src/components/QuestionnaireV2/form/StructuredSlot.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Suspense, useCallback, useSyncExternalStore } from "react";
import { Suspense, useCallback, useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";

import { PluginErrorBoundary } from "@/components/Common/PluginErrorBoundary";
import { FormSkeleton } from "@/components/Common/SkeletonLoading";

import {
useClearQuestionErrors,
useClearStructuredRenderFailed,
useMarkStructuredRenderFailed,
useQuestionErrors,
useQuestionResponse,
} from "@/components/QuestionnaireV2/form/engine/store";
Expand All @@ -23,6 +25,24 @@ import type { Question } from "@/types/questionnaire/question";

import { useFormRenderer } from "./FormContext";

/**
* Clears the question's render-failed mark when it mounts. Rendered INSIDE
* the error boundary, beside the structured component: if the component
* throws during the mounting render, the whole subtree — this probe
* included — is discarded before effects run, so the clear only ever fires
* for a commit whose input actually made it to the screen. That placement
* is what makes the mark track reality across an unmount/remount cycle
* (enable_when toggling the question): a recovered slot un-exempts itself,
* a still-broken one re-marks via the boundary's onError.
*/
function ClearRenderFailedOnMount({ questionId }: { questionId: string }) {
const clearRenderFailed = useClearStructuredRenderFailed(questionId);
useEffect(() => {
clearRenderFailed();
}, [clearRenderFailed]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The ClearRenderFailedOnMount probe is clever — it exploits the fact that a thrown render discards effects before they run. But there's a subtle gap: useEffect runs AFTER paint, not synchronously at commit. Between "the component successfully mounted" and "the effect fires and clears the mark," there's a render cycle where structuredRenderFailedAtom still has the old question ID in it.

In practice this race matters if something triggers a submit in that window — unlikely in normal use, but not impossible with a fast autosave or keyboard shortcut. A useLayoutEffect would close that window since it fires synchronously after DOM mutations before paint. The probe doesn't touch the DOM, so useLayoutEffect is safe here and would be strictly more correct.

return null;
}

/**
* Structured questions render through `resolveStructuredType` — core types
* from STRUCTURED_TYPE_REGISTRY, plugin types from the runtime registry,
Expand Down Expand Up @@ -56,6 +76,7 @@ export function StructuredSlot({
const [response, updateResponse] = useQuestionResponse(question.id);
const errors = useQuestionErrors(question.id);
const clearErrors = useClearQuestionErrors(question.id);
const markRenderFailed = useMarkStructuredRenderFailed(question.id);

const handleChange = useCallback(
(values: ResponseValue[], note?: string) =>
Expand Down Expand Up @@ -131,13 +152,18 @@ export function StructuredSlot({
// the same dashed notice the other degradations use.
<PluginErrorBoundary
pluginName={definition.type}
// Once the notice is showing there is no input to answer, so
// submit-time enforcement must stop requiring one — same reasoning
// as the subject-mismatch and missing-context skips.
onError={markRenderFailed}
fallback={
<div className="rounded-lg border border-dashed border-amber-300 bg-amber-50 p-4 text-sm text-amber-800">
<p className="font-medium">{label}</p>
<p>{t("structured_question_render_failed")}</p>
</div>
}
>
<ClearRenderFailedOnMount questionId={question.id} />
{/* Plugin components arrive through React.lazy — the boundary keeps
a still-loading remote from suspending the whole form. */}
<Suspense fallback={<FormSkeleton rows={2} />}>
Expand Down
53 changes: 53 additions & 0 deletions src/components/QuestionnaireV2/form/engine/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,59 @@ export const questionnaireAtom = atom<QuestionnaireRead | null>(null);
export const responsesAtom = atom<Record<string, QuestionnaireResponse>>({});
export const errorsAtom = atom<QuestionValidationError[]>([]);

/**
* Question ids whose structured slot THREW during render and are now
* showing the error boundary's notice instead of an input.
*
* Submit-time enforcement reads this alongside the subject-mismatch and
* missing-context cases: all three mean "this question has no input on
* screen", and requiring an unanswerable question makes the entire form —
* every other answer included — permanently unsubmittable. Lives in the
* store because the boundary that discovers it and the validators that
* must respect it never meet in the component tree.
*/
export const structuredRenderFailedAtom = atom<ReadonlySet<string>>(
new Set<string>(),
);

/** Record a structured slot's render failure. Idempotent: re-entering the
* boundary for a question already marked keeps the same Set identity, so
* it cannot loop a subscriber. */
export function useMarkStructuredRenderFailed(questionId: string) {
const markAtom = useMemo(
() =>
atom(null, (get, set) => {
const failed = get(structuredRenderFailedAtom);
if (failed.has(questionId)) return;
set(structuredRenderFailedAtom, new Set(failed).add(questionId));
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Creating write-only atoms inside useMemo works but it's non-standard Jotai. You're synthesizing a new atom class per questionId instead of using atomFamily from jotai/utils, which is the intended tool for exactly this pattern. The useMemo dependency is correct and it won't blow up — it's just going to confuse the next person who reads it and wonders why atoms are being manufactured in render.

[questionId],
);
return useAtom(markAtom)[1];
}

/** Clear a question's render-failed mark — the recovery half of the pair
* above. A slot unmounts and remounts whenever enable_when toggles it (or
* an ancestor group), and the fresh boundary may well render fine; the
* mark must not outlive the notice it described, or a LIVE required input
* would stay exempt from validation for the rest of the session.
* Idempotent the same way: clearing an unmarked question keeps the Set
* identity. */
export function useClearStructuredRenderFailed(questionId: string) {
const clearAtom = useMemo(
() =>
atom(null, (get, set) => {
const failed = get(structuredRenderFailedAtom);
if (!failed.has(questionId)) return;
const next = new Set(failed);
next.delete(questionId);
set(structuredRenderFailedAtom, next);
}),
[questionId],
);
return useAtom(clearAtom)[1];
}

/** link_id → question_id for enable_when lookups — pure so non-atom
* consumers (form/validation.ts) share the exact same resolution. */
export function buildLinkIndex(questions: Question[]): Record<string, string> {
Expand Down
Loading
Loading