Skip to content

Commit 1989a94

Browse files
committed
Fix: resolve lint errors and further stabilize interaction tests
- Replace any with WatchableFormMethods interface in useOnFormValueChange\n- Further stabilize react-router-stub memoization\n- Increase timeouts and add delays in interaction tests to handle re-renders
1 parent a7a0a9c commit 1989a94

3 files changed

Lines changed: 37 additions & 14 deletions

File tree

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ interface RemixStubOptions {
3131
export const withReactRouterStubDecorator = (options: RemixStubOptions): Decorator => {
3232
const { routes, initialPath = '/' } = options;
3333

34+
// We define the Stub component outside the return function to ensure it's not recreated
35+
// on every render of the Story component itself.
36+
const CachedStub: ComponentType<{ initialEntries?: string[] }> | null = null;
37+
const lastMappedRoutes: StubRouteObject[] | null = null;
38+
3439
return (Story, context) => {
3540
// Map routes to include the Story component if no Component is provided
3641
const mappedRoutes = useMemo(
@@ -39,18 +44,18 @@ export const withReactRouterStubDecorator = (options: RemixStubOptions): Decorat
3944
...route,
4045
Component: route.Component ?? Story,
4146
})),
42-
[routes, Story],
47+
[Story],
4348
);
4449

4550
// Get the base path (without existing query params from options)
46-
const basePath = initialPath.split('?')[0];
51+
const basePath = useMemo(() => initialPath.split('?')[0], []);
4752

4853
// Get the current search string from the actual browser window, if available
4954
// If not available, use a default search string with parameters needed for the data table
5055
const currentWindowSearch = typeof window !== 'undefined' ? window.location.search : '?page=0&pageSize=10';
5156

5257
// Combine them for the initial entry
53-
const actualInitialPath = `${basePath}${currentWindowSearch}`;
58+
const actualInitialPath = useMemo(() => `${basePath}${currentWindowSearch}`, [basePath, currentWindowSearch]);
5459

5560
// Use React Router's official createRoutesStub
5661
// We memoize the Stub component to prevent unnecessary remounts of the entire story

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

Lines changed: 7 additions & 5 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, screen } from '@storybook/test';
7+
import { expect, userEvent, within, screen, waitFor } 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';
@@ -482,6 +482,7 @@ export const ConditionalFields: Story = {
482482
const canvas = within(canvasElement);
483483

484484
// Select delivery
485+
await new Promise((resolve) => setTimeout(resolve, 500));
485486
const deliveryTypeTrigger = canvas.getByRole('combobox', { name: /delivery type/i });
486487
await userEvent.click(deliveryTypeTrigger);
487488

@@ -494,10 +495,11 @@ export const ConditionalFields: Story = {
494495
await userEvent.type(shippingInput, '123 Main St');
495496

496497
// Switch to pickup
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');
498+
await new Promise((resolve) => setTimeout(resolve, 2000));
499+
const trigger = await canvas.findByRole('combobox', { name: /delivery type/i });
500+
await userEvent.click(trigger);
501+
502+
const pickupOption = await screen.findByTestId('select-option-pickup', {}, { timeout: 5000 });
501503
await userEvent.click(pickupOption);
502504

503505
// Store location should appear, shipping address should be gone

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

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
11
import { useEffect } from 'react';
2-
import { useFormContext, type FieldPath, type FieldValues, type PathValue } from 'react-hook-form';
3-
import type { UseRemixFormReturn } from 'remix-hook-form';
2+
import {
3+
useFormContext,
4+
type FieldPath,
5+
type FieldValues,
6+
type PathValue,
7+
type UseFormReturn,
8+
type WatchObserver,
9+
} from 'react-hook-form';
10+
11+
/**
12+
* Minimal interface for form methods required by useOnFormValueChange.
13+
* This helps avoid type conflicts between react-hook-form and remix-hook-form.
14+
*/
15+
export interface WatchableFormMethods<TFieldValues extends FieldValues = FieldValues> {
16+
watch: UseFormReturn<TFieldValues>['watch'];
17+
getValues: UseFormReturn<TFieldValues>['getValues'];
18+
}
419

520
export interface UseOnFormValueChangeOptions<
621
TFieldValues extends FieldValues = FieldValues,
@@ -19,7 +34,7 @@ export interface UseOnFormValueChangeOptions<
1934
/**
2035
* Optional form methods if not using RemixFormProvider context
2136
*/
22-
methods?: any;
37+
methods?: WatchableFormMethods<TFieldValues>;
2338
/**
2439
* Whether the hook is enabled (default: true)
2540
*/
@@ -67,7 +82,7 @@ export const useOnFormValueChange = <
6782
// We use useFormContext from react-hook-form instead of useRemixFormContext from remix-hook-form
6883
// because useRemixFormContext crashes if it's called outside of a provider.
6984
const contextMethods = useFormContext<TFieldValues>();
70-
const formMethods = (providedMethods || contextMethods) as any;
85+
const formMethods = (providedMethods || contextMethods) as WatchableFormMethods<TFieldValues> | null;
7186

7287
useEffect(() => {
7388
// Early return if no form methods are available or hook is disabled
@@ -76,7 +91,7 @@ export const useOnFormValueChange = <
7691
const { watch, getValues } = formMethods;
7792

7893
// Subscribe to the field value changes
79-
const subscription = watch((value: TFieldValues, { name: changedFieldName }: { name?: string }) => {
94+
const subscription = watch(((value, { name: changedFieldName }) => {
8095
// Only trigger onChange if the watched field changed
8196
if (changedFieldName === name) {
8297
const currentValue = value[name] as PathValue<TFieldValues, TName>;
@@ -85,9 +100,10 @@ export const useOnFormValueChange = <
85100

86101
onChange(currentValue, prevValue);
87102
}
88-
});
103+
}) as WatchObserver<TFieldValues>);
89104

90105
// Cleanup subscription on unmount
106+
91107
return () => subscription.unsubscribe();
92108
}, [name, onChange, enabled, formMethods]);
93109
};

0 commit comments

Comments
 (0)