Skip to content

Commit 32b76be

Browse files
committed
fix: ensure stepped forms SSR hydration matches client closes #231
1 parent 4cb9201 commit 32b76be

File tree

3 files changed

+177
-7
lines changed

3 files changed

+177
-7
lines changed

.changeset/slimy-wings-think.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@formwerk/core': patch
3+
---
4+
5+
fix: Ensure stepped forms SSR hydration matches client closes #231

packages/core/src/useFormFlow/useFormFlow.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export function useFormFlow<TInput extends FormObject = FormObject>(_props?: For
3636
const segmentValuesMap = ref(new Map<string, StepState<TInput>>()) as Ref<Map<string, StepState<TInput>>>;
3737
const form = useForm(_props);
3838
const segments = ref<SegmentMetadata[]>([]);
39-
39+
const isSegmentsStable = ref(false);
4040
const rawCurrentSegment = computed(() => segments.value.find(segment => segment.id === currentSegmentId.value));
4141

4242
const [dispatchActiveSegmentChange, onActiveSegmentChange] = createEventDispatcher<{
@@ -78,20 +78,30 @@ export function useFormFlow<TInput extends FormObject = FormObject>(_props?: For
7878
provide(FormFlowContextKey, {
7979
isSegmentActive: (segmentId: string) => rawCurrentSegment.value?.id === segmentId,
8080
registerSegment: (metadata: SegmentRegistrationMetadata) => {
81+
const isFirstSegment = segments.value.length === 0;
82+
8183
segments.value.push({
8284
id: metadata.id,
8385
name: () => toValue(metadata.name),
8486
// The first segment is always visited.
85-
visited: segments.value.length === 0,
87+
visited: isFirstSegment,
8688
submitted: false,
8789
getValue: () => segmentValuesMap.value.get(metadata.id)?.values,
8890
});
8991

90-
// Activate the first segment if there is only one by default
91-
// Fixes SSR #231
92-
if (segments.value.length === 1) {
92+
// Activate the first segment immediately to ensure SSR/client consistency
93+
// Fixes SSR hydration mismatch with isActive state
94+
if (isFirstSegment) {
9395
currentSegmentId.value = metadata.id;
9496
}
97+
98+
// Mark segments as stable after a microtask to ensure all synchronous registrations complete
99+
// This helps distinguish between "only 1 segment registered so far" vs "only 1 segment total"
100+
if (!isSegmentsStable.value) {
101+
nextTick(() => {
102+
isSegmentsStable.value = true;
103+
});
104+
}
95105
},
96106
});
97107

@@ -113,7 +123,24 @@ export function useFormFlow<TInput extends FormObject = FormObject>(_props?: For
113123
return getRenderedSegments().findIndex(segment => segment.id === current.id);
114124
});
115125

116-
const isLastSegment = computed(() => currentSegmentIndex.value === segments.value.length - 1);
126+
const isLastSegment = computed(() => {
127+
const current = currentSegment.value;
128+
if (!current) {
129+
return false;
130+
}
131+
132+
const index = currentSegmentIndex.value;
133+
const total = segments.value.length;
134+
135+
// If we're on the first segment and only one segment has registered so far,
136+
// only return true if segments are stable (all registered).
137+
// This prevents SSR hydration mismatch when segments register incrementally on client.
138+
if (index === 0 && total === 1 && !isSegmentsStable.value) {
139+
return false;
140+
}
141+
142+
return index === total - 1;
143+
});
117144

118145
function resolveRelative(delta: number): SegmentMetadata | null {
119146
const domSegments = Array.from(

packages/core/src/useFormFlow/useStepFormFlow.spec.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineComponent } from 'vue';
1+
import { defineComponent, watch } from 'vue';
22
import { fireEvent, render, screen } from '@testing-library/vue';
33
import { axe } from 'vitest-axe';
44
import { StepResolveContext, useStepFormFlow } from '.';
@@ -1433,3 +1433,141 @@ describe('warnings', () => {
14331433
);
14341434
});
14351435
});
1436+
1437+
describe('single-step forms', () => {
1438+
test('should correctly identify isLastStep as true for single-step form', async () => {
1439+
await render({
1440+
components: {
1441+
SteppedFormFlow,
1442+
FormFlowSegment,
1443+
TextField,
1444+
},
1445+
template: `
1446+
<SteppedFormFlow>
1447+
<FormFlowSegment name="only-step">
1448+
<span>Only Step</span>
1449+
<TextField label="Name" name="name" />
1450+
</FormFlowSegment>
1451+
</SteppedFormFlow>
1452+
`,
1453+
});
1454+
1455+
await flush();
1456+
1457+
// With only one step, the next button should say "Submit" not "Next"
1458+
expect(screen.getByTestId('next-button')).toHaveTextContent('Submit');
1459+
});
1460+
1461+
test('should trigger onDone when submitting single-step form', async () => {
1462+
const onDone = vi.fn();
1463+
1464+
await render({
1465+
setup() {
1466+
return { onDone };
1467+
},
1468+
components: {
1469+
SteppedFormFlow,
1470+
FormFlowSegment,
1471+
TextField,
1472+
},
1473+
template: `
1474+
<SteppedFormFlow @done="onDone">
1475+
<FormFlowSegment name="only-step">
1476+
<TextField label="Name" name="name" />
1477+
</FormFlowSegment>
1478+
</SteppedFormFlow>
1479+
`,
1480+
});
1481+
1482+
await flush();
1483+
1484+
// Fill in the field
1485+
await fireEvent.update(screen.getByLabelText('Name'), 'John Doe');
1486+
await flush();
1487+
1488+
// Click next/submit button
1489+
await fireEvent.click(screen.getByTestId('next-button'));
1490+
await flush();
1491+
1492+
// Should have triggered onDone, not moved to another step
1493+
expect(onDone).toHaveBeenCalledWith({
1494+
name: 'John Doe',
1495+
});
1496+
});
1497+
1498+
test('should not change button text while segments are registering', async () => {
1499+
// This test verifies the segmentsStable flag works correctly
1500+
// by checking that isLastStep doesn't prematurely return true during registration
1501+
let buttonTexts: string[] = [];
1502+
1503+
await render({
1504+
setup() {
1505+
const { isLastStep } = useStepFormFlow();
1506+
1507+
// Track button text as it changes
1508+
watch(
1509+
() => isLastStep.value,
1510+
val => {
1511+
buttonTexts.push(val ? 'Submit' : 'Next');
1512+
},
1513+
{ immediate: true },
1514+
);
1515+
1516+
return { isLastStep };
1517+
},
1518+
components: {
1519+
FormFlowSegment,
1520+
TextField,
1521+
},
1522+
template: `
1523+
<div>
1524+
<FormFlowSegment name="step1">
1525+
<TextField label="Name" name="name" />
1526+
</FormFlowSegment>
1527+
<FormFlowSegment name="step2">
1528+
<TextField label="Email" name="email" />
1529+
</FormFlowSegment>
1530+
<FormFlowSegment name="step3">
1531+
<TextField label="Phone" name="phone" />
1532+
</FormFlowSegment>
1533+
<button>{{ isLastStep ? 'Submit' : 'Next' }}</button>
1534+
</div>
1535+
`,
1536+
});
1537+
1538+
await flush();
1539+
1540+
// The button should start as "Next" and not flicker to "Submit" during registration
1541+
// First value should be "Next" (not "Submit")
1542+
expect(buttonTexts[0]).toBe('Next');
1543+
1544+
// After all segments register and stabilize, should still be "Next" (we're on step 1 of 3)
1545+
expect(screen.getByRole('button')).toHaveTextContent('Next');
1546+
});
1547+
1548+
test('single-step form isLastStep should become true after segments stabilize', async () => {
1549+
await render({
1550+
components: {
1551+
SteppedFormFlow,
1552+
FormFlowSegment,
1553+
TextField,
1554+
},
1555+
template: `
1556+
<SteppedFormFlow>
1557+
<FormFlowSegment name="only-step">
1558+
<TextField label="Name" name="name" />
1559+
</FormFlowSegment>
1560+
</SteppedFormFlow>
1561+
`,
1562+
});
1563+
1564+
await flush();
1565+
1566+
// Wait an extra tick for the microtask (segmentsStable) to resolve
1567+
await new Promise(resolve => Promise.resolve().then(resolve));
1568+
await flush();
1569+
1570+
// After segments stabilize, single-step form should show "Submit"
1571+
expect(screen.getByTestId('next-button')).toHaveTextContent('Submit');
1572+
});
1573+
});

0 commit comments

Comments
 (0)