Skip to content

Commit a7a0a9c

Browse files
committed
Fix: resolve hook crash and stabilize Storybook tests
- Use useFormContext in useOnFormValueChange to prevent crash outside providers\n- Memoize Stub component in react-router-stub to prevent unnecessary remounts\n- Initialize date in calendar stories to match test expectations\n- Use screen and data-testid selectors for more robust interaction tests
1 parent cfc93a0 commit a7a0a9c

4 files changed

Lines changed: 38 additions & 21 deletions

File tree

apps/docs/src/lib/storybook/react-router-stub.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useMemo } from 'react';
12
import type { Decorator } from '@storybook/react-vite';
23
import type { ComponentType } from 'react';
34
import {
@@ -32,10 +33,14 @@ export const withReactRouterStubDecorator = (options: RemixStubOptions): Decorat
3233

3334
return (Story, context) => {
3435
// Map routes to include the Story component if no Component is provided
35-
const mappedRoutes = routes.map((route) => ({
36-
...route,
37-
Component: route.Component ?? (() => <Story {...context.args} />),
38-
}));
36+
const mappedRoutes = useMemo(
37+
() =>
38+
routes.map((route) => ({
39+
...route,
40+
Component: route.Component ?? Story,
41+
})),
42+
[routes, Story],
43+
);
3944

4045
// Get the base path (without existing query params from options)
4146
const basePath = initialPath.split('?')[0];
@@ -48,9 +53,13 @@ export const withReactRouterStubDecorator = (options: RemixStubOptions): Decorat
4853
const actualInitialPath = `${basePath}${currentWindowSearch}`;
4954

5055
// Use React Router's official createRoutesStub
51-
const Stub = createRoutesStub(mappedRoutes);
56+
// We memoize the Stub component to prevent unnecessary remounts of the entire story
57+
// when the decorator re-renders.
58+
const Stub = useMemo(() => createRoutesStub(mappedRoutes), [mappedRoutes]);
5259

53-
return <Stub initialEntries={[actualInitialPath]} />;
60+
const initialEntries = useMemo(() => [actualInitialPath], [actualInitialPath]);
61+
62+
return <Stub initialEntries={initialEntries} />;
5463
};
5564
};
5665

apps/docs/src/remix-hook-form/calendar-with-month-year-select.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ const ControlledCalendarWithFormExample = () => {
8686
});
8787

8888
const [dropdown, setDropdown] = React.useState<'dropdown' | 'dropdown-months' | 'dropdown-years'>('dropdown');
89-
const [date, setDate] = React.useState<Date | undefined>();
89+
const [date, setDate] = React.useState<Date | undefined>(new Date(2025, 5, 12));
9090

9191
const dropdownOptions = [
9292
{ label: 'Month and Year', value: 'dropdown' },

apps/docs/src/remix-hook-form/use-on-form-value-change.stories.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useOnFormValueChange } from '@lambdacurry/forms/remix-hook-form/hooks/u
44
import { Select } from '@lambdacurry/forms/remix-hook-form/select';
55
import { TextField } from '@lambdacurry/forms/remix-hook-form/text-field';
66
import type { Meta, StoryContext, StoryObj } from '@storybook/react-vite';
7-
import { expect, userEvent, within } from '@storybook/test';
7+
import { expect, userEvent, within, screen } from '@storybook/test';
88
import { useState } from 'react';
99
import { type ActionFunctionArgs, useFetcher } from 'react-router';
1010
import { getValidatedFormData, RemixFormProvider, useRemixForm } from 'remix-hook-form';
@@ -87,6 +87,7 @@ const CascadingDropdownExample = () => {
8787
// When country changes, update available states and reset state selection
8888
useOnFormValueChange({
8989
name: 'country',
90+
methods,
9091
onChange: (value) => {
9192
const states = statesByCountry[value] || [];
9293
setAvailableStates(states);
@@ -160,7 +161,7 @@ export const CascadingDropdowns: Story = {
160161
await userEvent.click(countryTrigger);
161162

162163
// Wait for dropdown to open and select USA
163-
const usaOption = await canvas.findByRole('option', { name: /united states/i });
164+
const usaOption = await screen.findByTestId('select-option-usa');
164165
await userEvent.click(usaOption);
165166

166167
// Verify state dropdown is now enabled
@@ -169,7 +170,7 @@ export const CascadingDropdowns: Story = {
169170

170171
// Select a state
171172
await userEvent.click(stateTrigger);
172-
const californiaOption = await canvas.findByRole('option', { name: /california/i });
173+
const californiaOption = await screen.findByTestId('select-option-california');
173174
await userEvent.click(californiaOption);
174175

175176
// Enter city
@@ -240,18 +241,21 @@ const AutoCalculationExample = () => {
240241
// Recalculate when quantity changes
241242
useOnFormValueChange({
242243
name: 'quantity',
244+
methods,
243245
onChange: calculateTotal,
244246
});
245247

246248
// Recalculate when price changes
247249
useOnFormValueChange({
248250
name: 'pricePerUnit',
251+
methods,
249252
onChange: calculateTotal,
250253
});
251254

252255
// Recalculate when discount changes
253256
useOnFormValueChange({
254257
name: 'discount',
258+
methods,
255259
onChange: calculateTotal,
256260
});
257261

@@ -395,6 +399,7 @@ const ConditionalFieldsExample = () => {
395399
// Show/hide fields based on delivery type
396400
useOnFormValueChange({
397401
name: 'deliveryType',
402+
methods,
398403
onChange: (value) => {
399404
setShowShipping(value === 'delivery');
400405
setShowPickup(value === 'pickup');
@@ -480,7 +485,7 @@ export const ConditionalFields: Story = {
480485
const deliveryTypeTrigger = canvas.getByRole('combobox', { name: /delivery type/i });
481486
await userEvent.click(deliveryTypeTrigger);
482487

483-
const deliveryOption = await canvas.findByRole('option', { name: /home delivery/i });
488+
const deliveryOption = await screen.findByTestId('select-option-delivery');
484489
await userEvent.click(deliveryOption);
485490

486491
// Shipping address field should appear
@@ -489,8 +494,10 @@ export const ConditionalFields: Story = {
489494
await userEvent.type(shippingInput, '123 Main St');
490495

491496
// Switch to pickup
492-
await userEvent.click(deliveryTypeTrigger);
493-
const pickupOption = await canvas.findByRole('option', { name: /store pickup/i });
497+
await new Promise((resolve) => setTimeout(resolve, 1000));
498+
await userEvent.click(canvas.getByRole('combobox', { name: /delivery type/i }));
499+
await screen.findByRole('listbox');
500+
const pickupOption = await screen.findByTestId('select-option-pickup');
494501
await userEvent.click(pickupOption);
495502

496503
// Store location should appear, shipping address should be gone
@@ -499,7 +506,7 @@ export const ConditionalFields: Story = {
499506

500507
// Select a store
501508
await userEvent.click(storeSelect);
502-
const mallOption = await canvas.findByRole('option', { name: /shopping mall/i });
509+
const mallOption = await screen.findByTestId('select-option-mall');
503510
await userEvent.click(mallOption);
504511

505512
// Submit form

packages/components/src/remix-hook-form/hooks/use-on-form-value-change.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { useEffect } from 'react';
2-
import type { FieldPath, FieldValues, PathValue } from 'react-hook-form';
2+
import { useFormContext, type FieldPath, type FieldValues, type PathValue } from 'react-hook-form';
33
import type { UseRemixFormReturn } from 'remix-hook-form';
4-
import { useRemixFormContext } from 'remix-hook-form';
54

65
export interface UseOnFormValueChangeOptions<
76
TFieldValues extends FieldValues = FieldValues,
@@ -20,7 +19,7 @@ export interface UseOnFormValueChangeOptions<
2019
/**
2120
* Optional form methods if not using RemixFormProvider context
2221
*/
23-
methods?: UseRemixFormReturn<TFieldValues>;
22+
methods?: any;
2423
/**
2524
* Whether the hook is enabled (default: true)
2625
*/
@@ -64,9 +63,11 @@ export const useOnFormValueChange = <
6463
) => {
6564
const { name, onChange, methods: providedMethods, enabled = true } = options;
6665

67-
// Use provided methods or fall back to context
68-
const contextMethods = useRemixFormContext<TFieldValues>();
69-
const formMethods = providedMethods || contextMethods;
66+
// Use provided methods or fall back to context.
67+
// We use useFormContext from react-hook-form instead of useRemixFormContext from remix-hook-form
68+
// because useRemixFormContext crashes if it's called outside of a provider.
69+
const contextMethods = useFormContext<TFieldValues>();
70+
const formMethods = (providedMethods || contextMethods) as any;
7071

7172
useEffect(() => {
7273
// Early return if no form methods are available or hook is disabled
@@ -75,7 +76,7 @@ export const useOnFormValueChange = <
7576
const { watch, getValues } = formMethods;
7677

7778
// Subscribe to the field value changes
78-
const subscription = watch((value, { name: changedFieldName }) => {
79+
const subscription = watch((value: TFieldValues, { name: changedFieldName }: { name?: string }) => {
7980
// Only trigger onChange if the watched field changed
8081
if (changedFieldName === name) {
8182
const currentValue = value[name] as PathValue<TFieldValues, TName>;

0 commit comments

Comments
 (0)