@fogpipe/forma-react 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,436 @@
1
+ /**
2
+ * Tests documenting FEEL null handling behavior in visibility expressions
3
+ *
4
+ * These tests document the behavior where FEEL expressions that evaluate to `null`
5
+ * (due to undefined field references) are silently converted to `false` by
6
+ * the forma-core engine. This can cause unexpected behavior where pages and fields
7
+ * become invisible not because the condition is false, but because it couldn't
8
+ * be evaluated.
9
+ *
10
+ * Root cause: forma-core's evaluateBoolean() converts null to false without warning.
11
+ */
12
+
13
+ import { describe, it, expect } from "vitest";
14
+ import { renderHook, act } from "@testing-library/react";
15
+ import { useForma } from "../useForma.js";
16
+ import { createTestSpec } from "./test-utils.js";
17
+
18
+ describe("FEEL null handling in visibility expressions", () => {
19
+ describe("boolean field null checks", () => {
20
+ it("should treat undefined boolean as not matching '= true'", () => {
21
+ const spec = createTestSpec({
22
+ fields: {
23
+ accepted: { type: "boolean", label: "Accept terms" },
24
+ details: {
25
+ type: "text",
26
+ label: "Details",
27
+ visibleWhen: "accepted = true",
28
+ },
29
+ },
30
+ });
31
+
32
+ const { result } = renderHook(() => useForma({ spec }));
33
+
34
+ // Field is undefined initially
35
+ expect(result.current.data.accepted).toBeUndefined();
36
+
37
+ // The visibility expression "accepted = true" evaluates to null when accepted is undefined
38
+ // forma-core converts this null to false, so the field is hidden
39
+ // This happens to be the "correct" behavior by accident
40
+ expect(result.current.visibility.details).toBe(false);
41
+
42
+ // When user explicitly sets to true, field becomes visible
43
+ act(() => {
44
+ result.current.setFieldValue("accepted", true);
45
+ });
46
+ expect(result.current.visibility.details).toBe(true);
47
+ });
48
+
49
+ it("should treat undefined boolean as not matching '= false'", () => {
50
+ const spec = createTestSpec({
51
+ fields: {
52
+ signingOnBehalf: { type: "boolean", label: "Signing on behalf?" },
53
+ participantName: {
54
+ type: "text",
55
+ label: "Participant Name",
56
+ visibleWhen: "signingOnBehalf = false",
57
+ },
58
+ },
59
+ });
60
+
61
+ const { result } = renderHook(() => useForma({ spec }));
62
+
63
+ // Field is undefined initially
64
+ expect(result.current.data.signingOnBehalf).toBeUndefined();
65
+
66
+ // The visibility expression "signingOnBehalf = false" evaluates to null when undefined
67
+ // This means the field is hidden even though the user hasn't made a choice yet
68
+ // This is problematic - the field should arguably be visible until the user chooses "true"
69
+ expect(result.current.visibility.participantName).toBe(false);
70
+
71
+ // Only becomes visible when explicitly set to false
72
+ act(() => {
73
+ result.current.setFieldValue("signingOnBehalf", false);
74
+ });
75
+ expect(result.current.visibility.participantName).toBe(true);
76
+ });
77
+
78
+ it("should demonstrate the != null pattern also returns null on undefined", () => {
79
+ const spec = createTestSpec({
80
+ fields: {
81
+ accepted: { type: "boolean", label: "Accept" },
82
+ },
83
+ computed: {
84
+ hasAnswered: {
85
+ expression: "accepted != null",
86
+ },
87
+ },
88
+ });
89
+
90
+ const { result } = renderHook(() => useForma({ spec }));
91
+
92
+ // When accepted is undefined, "accepted != null" in FEEL returns null (not true or false)
93
+ // This is because FEEL's null comparison semantics are different from JavaScript
94
+ // The computed value becomes null, which may cause downstream issues
95
+ expect(result.current.data.accepted).toBeUndefined();
96
+
97
+ // Note: computed values that are null may show as null or undefined
98
+ // depending on how forma-core handles them
99
+ const hasAnsweredValue = result.current.computed?.hasAnswered;
100
+ // This documents the actual behavior - it may be null instead of false
101
+ expect(hasAnsweredValue === null || hasAnsweredValue === false).toBe(true);
102
+ });
103
+ });
104
+
105
+ describe("computed field dependency chains", () => {
106
+ it("should propagate null through computed field chains", () => {
107
+ const spec = createTestSpec({
108
+ fields: {
109
+ age: { type: "number", label: "Age" },
110
+ income: { type: "number", label: "Income" },
111
+ },
112
+ computed: {
113
+ // First level - checks if age is provided
114
+ hasAge: { expression: "age != null" },
115
+ // Second level - depends on hasAge
116
+ canProceed: { expression: "computed.hasAge = true" },
117
+ },
118
+ });
119
+
120
+ const { result } = renderHook(() => useForma({ spec }));
121
+
122
+ // Both fields undefined
123
+ expect(result.current.data.age).toBeUndefined();
124
+
125
+ // The chain: age is undefined → hasAge evaluates to null → canProceed evaluates to null
126
+ // Both computed values should be null or false due to the dependency chain
127
+ const computed = result.current.computed;
128
+ expect(computed?.hasAge === null || computed?.hasAge === false).toBe(true);
129
+ expect(computed?.canProceed === null || computed?.canProceed === false).toBe(true);
130
+
131
+ // When we provide a value, the chain resolves
132
+ act(() => {
133
+ result.current.setFieldValue("age", 25);
134
+ });
135
+
136
+ expect(result.current.computed?.hasAge).toBe(true);
137
+ expect(result.current.computed?.canProceed).toBe(true);
138
+ });
139
+
140
+ it("should cause page visibility issues when computed chains return null", () => {
141
+ const spec = createTestSpec({
142
+ fields: {
143
+ accepted: { type: "boolean", label: "Accepted" },
144
+ details: { type: "text", label: "Details" },
145
+ },
146
+ computed: {
147
+ isAccepted: { expression: "accepted = true" },
148
+ },
149
+ pages: [
150
+ { id: "page1", title: "Accept", fields: ["accepted"] },
151
+ {
152
+ id: "page2",
153
+ title: "Details",
154
+ fields: ["details"],
155
+ visibleWhen: "computed.isAccepted = true",
156
+ },
157
+ ],
158
+ });
159
+
160
+ const { result } = renderHook(() => useForma({ spec }));
161
+
162
+ // With undefined accepted, computed.isAccepted is null
163
+ // Page visibility "computed.isAccepted = true" → null = true → null → false
164
+ const pages = result.current.wizard?.pages;
165
+ expect(pages?.[0].visible).toBe(true); // First page always visible
166
+ expect(pages?.[1].visible).toBe(false); // Second page hidden due to null chain
167
+
168
+ // After accepting, the page becomes visible
169
+ act(() => {
170
+ result.current.setFieldValue("accepted", true);
171
+ });
172
+
173
+ expect(result.current.wizard?.pages?.[1].visible).toBe(true);
174
+ });
175
+ });
176
+
177
+ describe("string function null handling", () => {
178
+ it("should handle string length on undefined values", () => {
179
+ const spec = createTestSpec({
180
+ fields: {
181
+ name: { type: "text", label: "Name" },
182
+ greeting: {
183
+ type: "text",
184
+ label: "Greeting",
185
+ // This pattern is unsafe - string length(undefined) returns null
186
+ visibleWhen: "string length(name) > 0",
187
+ },
188
+ },
189
+ });
190
+
191
+ const { result } = renderHook(() => useForma({ spec }));
192
+
193
+ // name is undefined
194
+ expect(result.current.data.name).toBeUndefined();
195
+
196
+ // string length(undefined) returns null in FEEL
197
+ // null > 0 returns null
198
+ // evaluateBoolean converts null to false
199
+ expect(result.current.visibility.greeting).toBe(false);
200
+
201
+ // Even empty string returns false (correct behavior)
202
+ act(() => {
203
+ result.current.setFieldValue("name", "");
204
+ });
205
+ expect(result.current.visibility.greeting).toBe(false);
206
+
207
+ // Non-empty string works correctly
208
+ act(() => {
209
+ result.current.setFieldValue("name", "John");
210
+ });
211
+ expect(result.current.visibility.greeting).toBe(true);
212
+ });
213
+
214
+ it("should work with null-safe string length pattern", () => {
215
+ const spec = createTestSpec({
216
+ fields: {
217
+ name: { type: "text", label: "Name" },
218
+ greeting: {
219
+ type: "text",
220
+ label: "Greeting",
221
+ // Null-safe pattern: check for null before using string length
222
+ visibleWhen: "name != null and string length(name) > 0",
223
+ },
224
+ },
225
+ });
226
+
227
+ const { result } = renderHook(() => useForma({ spec }));
228
+
229
+ // name is undefined - first part of AND fails, but returns null not false
230
+ // The behavior is the same as the unsafe pattern in this case
231
+ expect(result.current.visibility.greeting).toBe(false);
232
+
233
+ act(() => {
234
+ result.current.setFieldValue("name", "John");
235
+ });
236
+ expect(result.current.visibility.greeting).toBe(true);
237
+ });
238
+ });
239
+
240
+ describe("alternative patterns for null safety", () => {
241
+ it("should use '!= true' pattern for safer boolean checks", () => {
242
+ const spec = createTestSpec({
243
+ fields: {
244
+ signingOnBehalf: { type: "boolean", label: "Signing on behalf?" },
245
+ participantFields: {
246
+ type: "text",
247
+ label: "Participant Name",
248
+ // Using != true instead of = false
249
+ // When undefined: undefined != true → should work better
250
+ visibleWhen: "signingOnBehalf != true",
251
+ },
252
+ },
253
+ });
254
+
255
+ const { result } = renderHook(() => useForma({ spec }));
256
+
257
+ // Test if this pattern is actually safer
258
+ // Note: In FEEL, undefined != true may still return null
259
+ // This test documents the actual behavior
260
+ const visibility = result.current.visibility.participantFields;
261
+
262
+ // Document what actually happens - this may still be false due to null handling
263
+ expect(typeof visibility).toBe("boolean");
264
+ });
265
+
266
+ it("should use explicit 'or' pattern for boolean checks", () => {
267
+ const spec = createTestSpec({
268
+ fields: {
269
+ accepted: { type: "boolean", label: "Accepted" },
270
+ },
271
+ computed: {
272
+ // Safer pattern: explicitly check for both true and false
273
+ hasAnswered: {
274
+ expression: "accepted = true or accepted = false",
275
+ },
276
+ },
277
+ });
278
+
279
+ const { result } = renderHook(() => useForma({ spec }));
280
+
281
+ // When undefined: (undefined = true) or (undefined = false)
282
+ // → null or null → null (FEEL or short-circuits on null)
283
+ // This pattern may not actually help with FEEL's null semantics
284
+ expect(result.current.data.accepted).toBeUndefined();
285
+
286
+ // Set to true
287
+ act(() => {
288
+ result.current.setFieldValue("accepted", true);
289
+ });
290
+ expect(result.current.computed?.hasAnswered).toBe(true);
291
+
292
+ // Set to false
293
+ act(() => {
294
+ result.current.setFieldValue("accepted", false);
295
+ });
296
+ expect(result.current.computed?.hasAnswered).toBe(true);
297
+ });
298
+ });
299
+ });
300
+
301
+ describe("page navigation with conditional visibility", () => {
302
+ it("should handle complex eligibility determination pattern", () => {
303
+ // This test reproduces the diabetes trial enrollment pattern
304
+ const spec = createTestSpec({
305
+ fields: {
306
+ // Inclusion criteria (all must be true to be eligible)
307
+ ageOk: { type: "boolean", label: "Age between 18-65" },
308
+ diagnosisOk: { type: "boolean", label: "Has diabetes diagnosis" },
309
+ // Exclusion criteria (all must be false to be eligible)
310
+ pregnant: { type: "boolean", label: "Is pregnant" },
311
+ allergy: { type: "boolean", label: "Has drug allergy" },
312
+ // Consent page field
313
+ consent: { type: "boolean", label: "I consent" },
314
+ },
315
+ computed: {
316
+ // Check if all inclusion answered
317
+ allInclusionAnswered: {
318
+ expression: "ageOk != null and diagnosisOk != null",
319
+ },
320
+ // Check if all exclusion answered
321
+ allExclusionAnswered: {
322
+ expression: "pregnant != null and allergy != null",
323
+ },
324
+ // Eligibility determined when all criteria answered
325
+ eligibilityDetermined: {
326
+ expression:
327
+ "computed.allInclusionAnswered = true and computed.allExclusionAnswered = true",
328
+ },
329
+ // All inclusion criteria met
330
+ allInclusionMet: {
331
+ expression: "ageOk = true and diagnosisOk = true",
332
+ },
333
+ // Any exclusion criteria met
334
+ anyExclusionMet: {
335
+ expression: "pregnant = true or allergy = true",
336
+ },
337
+ // Final eligibility
338
+ eligible: {
339
+ expression:
340
+ "computed.eligibilityDetermined = true and computed.allInclusionMet = true and computed.anyExclusionMet = false",
341
+ },
342
+ },
343
+ pages: [
344
+ { id: "inclusion", title: "Inclusion Criteria", fields: ["ageOk", "diagnosisOk"] },
345
+ { id: "exclusion", title: "Exclusion Criteria", fields: ["pregnant", "allergy"] },
346
+ {
347
+ id: "consent",
348
+ title: "Consent",
349
+ fields: ["consent"],
350
+ visibleWhen: "computed.eligible = true",
351
+ },
352
+ ],
353
+ });
354
+
355
+ const { result } = renderHook(() => useForma({ spec }));
356
+
357
+ // Initially, all computed values are null due to undefined fields
358
+ const wizard = result.current.wizard;
359
+ expect(wizard?.pages[0].visible).toBe(true); // Inclusion always visible
360
+ expect(wizard?.pages[1].visible).toBe(true); // Exclusion always visible
361
+ expect(wizard?.pages[2].visible).toBe(false); // Consent hidden (computed.eligible is null)
362
+
363
+ // Fill inclusion criteria (both true)
364
+ act(() => {
365
+ result.current.setFieldValue("ageOk", true);
366
+ result.current.setFieldValue("diagnosisOk", true);
367
+ });
368
+
369
+ // Still not eligible - exclusion not answered yet
370
+ expect(result.current.wizard?.pages[2].visible).toBe(false);
371
+
372
+ // Fill exclusion criteria (both false - no exclusions met)
373
+ act(() => {
374
+ result.current.setFieldValue("pregnant", false);
375
+ result.current.setFieldValue("allergy", false);
376
+ });
377
+
378
+ // Now eligible - consent page should be visible
379
+ expect(result.current.computed?.eligibilityDetermined).toBe(true);
380
+ expect(result.current.computed?.allInclusionMet).toBe(true);
381
+ expect(result.current.computed?.anyExclusionMet).toBe(false);
382
+ expect(result.current.computed?.eligible).toBe(true);
383
+ expect(result.current.wizard?.pages[2].visible).toBe(true);
384
+ });
385
+
386
+ it("should show ineligibility when exclusion criteria met", () => {
387
+ const spec = createTestSpec({
388
+ fields: {
389
+ ageOk: { type: "boolean", label: "Age OK" },
390
+ pregnant: { type: "boolean", label: "Pregnant" },
391
+ details: { type: "text", label: "Details" },
392
+ ineligibilityReason: { type: "textarea", label: "Reason" },
393
+ },
394
+ computed: {
395
+ eligibilityDetermined: {
396
+ expression: "(ageOk = true or ageOk = false) and (pregnant = true or pregnant = false)",
397
+ },
398
+ eligible: {
399
+ expression: "computed.eligibilityDetermined = true and ageOk = true and pregnant = false",
400
+ },
401
+ ineligible: {
402
+ expression: "computed.eligibilityDetermined = true and (ageOk = false or pregnant = true)",
403
+ },
404
+ },
405
+ pages: [
406
+ { id: "screening", title: "Screening", fields: ["ageOk", "pregnant"] },
407
+ {
408
+ id: "eligible-flow",
409
+ title: "Continue",
410
+ fields: ["details"],
411
+ visibleWhen: "computed.eligible = true",
412
+ },
413
+ {
414
+ id: "ineligible-flow",
415
+ title: "Ineligible",
416
+ fields: ["ineligibilityReason"],
417
+ visibleWhen: "computed.ineligible = true",
418
+ },
419
+ ],
420
+ });
421
+
422
+ const { result } = renderHook(() => useForma({ spec }));
423
+
424
+ // Mark as pregnant (exclusion criterion met)
425
+ act(() => {
426
+ result.current.setFieldValue("ageOk", true);
427
+ result.current.setFieldValue("pregnant", true);
428
+ });
429
+
430
+ // Should show ineligible path, not eligible path
431
+ expect(result.current.computed?.eligible).toBe(false);
432
+ expect(result.current.computed?.ineligible).toBe(true);
433
+ expect(result.current.wizard?.pages[1].visible).toBe(false); // eligible-flow hidden
434
+ expect(result.current.wizard?.pages[2].visible).toBe(true); // ineligible-flow visible
435
+ });
436
+ });
@@ -47,6 +47,7 @@ export function createTestSpec(
47
47
  enum: (fieldOptions as Array<{ value: string }>).map((o) => o.value),
48
48
  ...rest,
49
49
  };
50
+ if (required) schemaRequired.push(name);
50
51
  continue;
51
52
  }
52
53
  }
@@ -571,6 +571,115 @@ describe("useForma", () => {
571
571
 
572
572
  expect(result.current.isSubmitted).toBe(false);
573
573
  });
574
+
575
+ describe("validation debouncing", () => {
576
+ it("should debounce validation updates when validationDebounceMs is set", async () => {
577
+ vi.useFakeTimers();
578
+
579
+ const spec = createTestSpec({
580
+ fields: {
581
+ name: { type: "text", required: true },
582
+ },
583
+ });
584
+
585
+ const { result } = renderHook(() =>
586
+ useForma({ spec, validationDebounceMs: 100 })
587
+ );
588
+
589
+ // Initially invalid (required field empty)
590
+ expect(result.current.isValid).toBe(false);
591
+
592
+ // Fill the field - validation should be debounced
593
+ act(() => {
594
+ result.current.setFieldValue("name", "John");
595
+ });
596
+
597
+ // Immediately after change, debounced validation still shows old state
598
+ // (depending on implementation, this might already be updated)
599
+
600
+ // Fast-forward past debounce timeout
601
+ await act(async () => {
602
+ vi.advanceTimersByTime(150);
603
+ });
604
+
605
+ // Now validation should reflect the new state
606
+ expect(result.current.isValid).toBe(true);
607
+
608
+ vi.useRealTimers();
609
+ });
610
+
611
+ it("should use immediate validation on submit even when debouncing", async () => {
612
+ vi.useFakeTimers();
613
+
614
+ const onSubmit = vi.fn();
615
+ const spec = createTestSpec({
616
+ fields: {
617
+ name: { type: "text", required: true },
618
+ },
619
+ });
620
+
621
+ const { result } = renderHook(() =>
622
+ useForma({ spec, validationDebounceMs: 500, onSubmit })
623
+ );
624
+
625
+ // Fill the field
626
+ act(() => {
627
+ result.current.setFieldValue("name", "John");
628
+ });
629
+
630
+ // Submit immediately without waiting for debounce
631
+ await act(async () => {
632
+ await result.current.submitForm();
633
+ });
634
+
635
+ // onSubmit should be called because immediate validation passes
636
+ expect(onSubmit).toHaveBeenCalledWith({ name: "John" });
637
+
638
+ vi.useRealTimers();
639
+ });
640
+
641
+ it("should not call onSubmit when immediate validation fails", async () => {
642
+ const onSubmit = vi.fn();
643
+ const spec = createTestSpec({
644
+ fields: {
645
+ name: { type: "text", required: true },
646
+ },
647
+ });
648
+
649
+ const { result } = renderHook(() =>
650
+ useForma({ spec, validationDebounceMs: 100, onSubmit })
651
+ );
652
+
653
+ // Submit without filling required field
654
+ await act(async () => {
655
+ await result.current.submitForm();
656
+ });
657
+
658
+ // onSubmit should NOT be called
659
+ expect(onSubmit).not.toHaveBeenCalled();
660
+ });
661
+
662
+ it("should work without debouncing (validationDebounceMs: 0)", () => {
663
+ const spec = createTestSpec({
664
+ fields: {
665
+ name: { type: "text", required: true },
666
+ },
667
+ });
668
+
669
+ const { result } = renderHook(() =>
670
+ useForma({ spec, validationDebounceMs: 0 })
671
+ );
672
+
673
+ expect(result.current.isValid).toBe(false);
674
+
675
+ act(() => {
676
+ result.current.setFieldValue("name", "John");
677
+ });
678
+
679
+ // Validation should update immediately
680
+ expect(result.current.isValid).toBe(true);
681
+ });
682
+ });
574
683
  });
575
684
 
576
685
  // ============================================================================
@@ -933,6 +1042,8 @@ describe("useForma", () => {
933
1042
  const page2After = result.current.wizard?.pages.find((p) => p.id === "page2");
934
1043
  expect(page2After?.visible).toBe(true);
935
1044
  });
1045
+
1046
+ // Note: Comprehensive canProceed tests are in canProceed.test.ts
936
1047
  });
937
1048
 
938
1049
  // ============================================================================
package/src/useForma.ts CHANGED
@@ -388,29 +388,57 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
388
388
 
389
389
  // For navigation, only count visible pages
390
390
  const visiblePages = pages.filter((p) => p.visible);
391
- const currentPage = visiblePages[state.currentPage] || null;
392
- const hasNextPage = state.currentPage < visiblePages.length - 1;
393
- const hasPreviousPage = state.currentPage > 0;
394
- const isLastPage = state.currentPage === visiblePages.length - 1;
391
+
392
+ // Clamp currentPage to valid range (handles case where current page becomes hidden)
393
+ const maxPageIndex = Math.max(0, visiblePages.length - 1);
394
+ const clampedPageIndex = Math.min(Math.max(0, state.currentPage), maxPageIndex);
395
+
396
+ // Auto-correct page index if it's out of bounds
397
+ if (clampedPageIndex !== state.currentPage && visiblePages.length > 0) {
398
+ dispatch({ type: "SET_PAGE", page: clampedPageIndex });
399
+ }
400
+
401
+ const currentPage = visiblePages[clampedPageIndex] || null;
402
+ const hasNextPage = clampedPageIndex < visiblePages.length - 1;
403
+ const hasPreviousPage = clampedPageIndex > 0;
404
+ const isLastPage = clampedPageIndex === visiblePages.length - 1;
395
405
 
396
406
  return {
397
407
  pages,
398
- currentPageIndex: state.currentPage,
408
+ currentPageIndex: clampedPageIndex,
399
409
  currentPage,
400
- goToPage: (index: number) => dispatch({ type: "SET_PAGE", page: index }),
410
+ goToPage: (index: number) => {
411
+ // Clamp to valid range
412
+ const validIndex = Math.min(Math.max(0, index), maxPageIndex);
413
+ dispatch({ type: "SET_PAGE", page: validIndex });
414
+ },
401
415
  nextPage: () => {
402
416
  if (hasNextPage) {
403
- dispatch({ type: "SET_PAGE", page: state.currentPage + 1 });
417
+ dispatch({ type: "SET_PAGE", page: clampedPageIndex + 1 });
404
418
  }
405
419
  },
406
420
  previousPage: () => {
407
421
  if (hasPreviousPage) {
408
- dispatch({ type: "SET_PAGE", page: state.currentPage - 1 });
422
+ dispatch({ type: "SET_PAGE", page: clampedPageIndex - 1 });
409
423
  }
410
424
  },
411
425
  hasNextPage,
412
426
  hasPreviousPage,
413
- canProceed: true, // TODO: Validate current page
427
+ canProceed: (() => {
428
+ if (!currentPage) return true;
429
+ // Get errors only for visible fields on the current page
430
+ const pageErrors = validation.errors.filter((e) => {
431
+ // Check if field is on current page (including array items like "items[0].name")
432
+ const isOnCurrentPage = currentPage.fields.includes(e.field) ||
433
+ currentPage.fields.some(f => e.field.startsWith(`${f}[`));
434
+ // Only count errors for visible fields
435
+ const isVisible = visibility[e.field] !== false;
436
+ // Only count actual errors, not warnings
437
+ const isError = e.severity === 'error';
438
+ return isOnCurrentPage && isVisible && isError;
439
+ });
440
+ return pageErrors.length === 0;
441
+ })(),
414
442
  isLastPage,
415
443
  touchCurrentPageFields: () => {
416
444
  if (currentPage) {
@@ -427,7 +455,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
427
455
  return pageErrors.length === 0;
428
456
  },
429
457
  };
430
- }, [spec, state.data, state.currentPage, computed, validation]);
458
+ }, [spec, state.data, state.currentPage, computed, validation, visibility]);
431
459
 
432
460
  // Helper to get value at nested path
433
461
  const getValueAtPath = useCallback((path: string): unknown => {