Skip to content

Commit 276db2a

Browse files
authored
fix: lens extensions (#49)
1 parent 6edb29d commit 276db2a

File tree

5 files changed

+149
-35
lines changed

5 files changed

+149
-35
lines changed

.github/workflows/ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,19 @@ jobs:
2323
- name: Prettier
2424
run: bun prettier
2525

26+
typecheck:
27+
name: Typecheck
28+
runs-on: ubuntu-latest
29+
steps:
30+
- name: Checkout repository
31+
uses: actions/checkout@v4
32+
33+
- name: Setup environment
34+
uses: ./.github/actions/setup
35+
36+
- name: Run typecheck
37+
run: bun typecheck
38+
2639
test-unit:
2740
name: Test Unit
2841
runs-on: ubuntu-latest

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@
5454
"prettier:fix": "prettier --write '**/*.md'",
5555
"storybook": "storybook dev -p 6006",
5656
"typecheck": "tsc --noEmit",
57-
"test": "vitest watch",
57+
"test": "vitest",
58+
"test:watch": "vitest watch",
5859
"test:e2e": "vitest run --project=e2e --coverage",
5960
"test:unit": "vitest run --project=unit --coverage"
6061
},

src/LensCore.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export class LensCore<T extends FieldValues> {
3333
control: Control<TFieldValues>,
3434
cache?: LensesStorage<TFieldValues>,
3535
): Lens<TFieldValues> {
36-
return new LensCore(control, '', cache) as unknown as Lens<TFieldValues>;
36+
return new this(control, '', cache) as unknown as Lens<TFieldValues>;
3737
}
3838

3939
public focus(prop: string | number): LensCore<T> {
@@ -57,7 +57,7 @@ export class LensCore<T extends FieldValues> {
5757

5858
if (Array.isArray(this.override)) {
5959
const [template] = this.override;
60-
const result = new LensCore(this.control, nestedPath, this.cache);
60+
const result = new (this.constructor as typeof LensCore)(this.control, nestedPath, this.cache);
6161
result.isArrayItemReflection = true;
6262
result.override = template;
6363

@@ -68,14 +68,14 @@ export class LensCore<T extends FieldValues> {
6868
const overriddenLens: LensCore<T> | undefined = get(this.override, propString);
6969

7070
if (!overriddenLens) {
71-
const result = new LensCore(this.control, nestedPath, this.cache);
71+
const result = new (this.constructor as typeof LensCore)(this.control, nestedPath, this.cache);
7272
this.cache?.set(result, nestedPath);
7373
return result;
7474
}
7575

7676
if (this.isArrayItemReflection) {
7777
const arrayItemNestedPath = `${this.path}.${overriddenLens.path}`;
78-
const result = new LensCore(this.control, arrayItemNestedPath, this.cache);
78+
const result = new (this.constructor as typeof LensCore)(this.control, arrayItemNestedPath, this.cache);
7979
this.cache?.set(result, arrayItemNestedPath);
8080
return result;
8181
} else {
@@ -84,7 +84,7 @@ export class LensCore<T extends FieldValues> {
8484
}
8585
}
8686

87-
const result = new LensCore(this.control, nestedPath, this.cache);
87+
const result = new (this.constructor as typeof LensCore)(this.control, nestedPath, this.cache);
8888
this.cache?.set(result, nestedPath);
8989
return result;
9090
}
@@ -102,7 +102,7 @@ export class LensCore<T extends FieldValues> {
102102
}
103103

104104
const nestedCache = new LensesStorage(this.control);
105-
const template = new LensCore(this.control, this.path, nestedCache);
105+
const template = new (this.constructor as typeof LensCore)(this.control, this.path, nestedCache);
106106

107107
const dictionary = new Proxy(
108108
{},
@@ -120,7 +120,7 @@ export class LensCore<T extends FieldValues> {
120120
const override = getter(dictionary, template);
121121

122122
if (Array.isArray(override)) {
123-
const result = new LensCore(this.control, this.path, this.cache);
123+
const result = new (this.constructor as typeof LensCore)(this.control, this.path, this.cache);
124124
template.path = '';
125125
result.override = getter(dictionary, template);
126126
result.reflectedKey = getter;

tests/lens-extension.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import type { FieldValues } from 'react-hook-form';
2+
import { useForm } from 'react-hook-form';
3+
import { LensCore, LensesStorage } from '@hookform/lenses';
4+
import { renderHook } from '@testing-library/react';
5+
6+
// Create a custom LensCore class that extends the base class
7+
class CustomLensCore<T extends FieldValues> extends LensCore<T> {
8+
// Add a custom method
9+
public getValue(): string {
10+
return `Custom value at path: ${this.path}`;
11+
}
12+
13+
// Add another custom method
14+
public getCustomInfo(): string {
15+
return `CustomLensCore instance at ${this.path}`;
16+
}
17+
}
18+
19+
test('LensCore extension should preserve custom methods when using focus()', () => {
20+
const { result } = renderHook(() =>
21+
useForm<{
22+
user: {
23+
name: string;
24+
email: string;
25+
};
26+
}>({
27+
defaultValues: {
28+
user: {
29+
name: 'John',
30+
31+
},
32+
},
33+
}),
34+
);
35+
36+
const control = result.current.control;
37+
const cache = new LensesStorage(control);
38+
const rootLens = new CustomLensCore(control, '', cache);
39+
40+
// Focus on nested paths
41+
const userLens = rootLens.focus('user');
42+
const nameLens = userLens.focus('name');
43+
44+
// Verify that the focused lenses are instances of CustomLensCore
45+
expect(userLens).toBeInstanceOf(CustomLensCore);
46+
expect(nameLens).toBeInstanceOf(CustomLensCore);
47+
48+
// Verify that custom methods are available
49+
expect((userLens as CustomLensCore<any>).getValue()).toBe('Custom value at path: user');
50+
expect((userLens as CustomLensCore<any>).getCustomInfo()).toBe('CustomLensCore instance at user');
51+
52+
expect((nameLens as CustomLensCore<any>).getValue()).toBe('Custom value at path: user.name');
53+
expect((nameLens as CustomLensCore<any>).getCustomInfo()).toBe('CustomLensCore instance at user.name');
54+
});
55+
56+
test('LensCore extension should preserve custom methods when using reflect()', () => {
57+
const { result } = renderHook(() =>
58+
useForm<{
59+
profile: {
60+
contact: {
61+
firstName: string;
62+
phone: string;
63+
};
64+
};
65+
}>({
66+
defaultValues: {
67+
profile: {
68+
contact: {
69+
firstName: 'Jane',
70+
phone: '123-456-7890',
71+
},
72+
},
73+
},
74+
}),
75+
);
76+
77+
const control = result.current.control;
78+
const cache = new LensesStorage(control);
79+
const rootLens = new CustomLensCore(control, '', cache);
80+
81+
// Use reflect to restructure
82+
const contactLens = rootLens.reflect((_dictionary, l) => ({
83+
name: l.focus('profile.contact.firstName'),
84+
phoneNumber: l.focus('profile.contact.phone'),
85+
}));
86+
87+
// Verify that the reflected lens is an instance of CustomLensCore
88+
expect(contactLens).toBeInstanceOf(CustomLensCore);
89+
expect((contactLens as CustomLensCore<any>).getValue()).toBe('Custom value at path: ');
90+
expect((contactLens as CustomLensCore<any>).getCustomInfo()).toBe('CustomLensCore instance at ');
91+
92+
// Focus on the reflected structure
93+
const nameLens = contactLens.focus('name');
94+
expect(nameLens).toBeInstanceOf(CustomLensCore);
95+
});
96+
97+
test('LensCore extension should preserve custom methods when using static create()', () => {
98+
const { result } = renderHook(() =>
99+
useForm<{
100+
firstName: string;
101+
lastName: string;
102+
}>({
103+
defaultValues: {
104+
firstName: 'Test',
105+
lastName: 'User',
106+
},
107+
}),
108+
);
109+
110+
const control = result.current.control;
111+
const cache = new LensesStorage(control);
112+
113+
// Use the static create method on the custom class
114+
const lens = CustomLensCore.create(control, cache);
115+
116+
// Verify that it creates an instance of CustomLensCore
117+
expect(lens).toBeInstanceOf(CustomLensCore);
118+
expect((lens as unknown as CustomLensCore<any>).getValue()).toBe('Custom value at path: ');
119+
expect((lens as unknown as CustomLensCore<any>).getCustomInfo()).toBe('CustomLensCore instance at ');
120+
121+
// Verify that focused lenses also preserve the custom class
122+
const firstNameLens = lens.focus('firstName');
123+
expect(firstNameLens).toBeInstanceOf(CustomLensCore);
124+
expect((firstNameLens as unknown as CustomLensCore<any>).getValue()).toBe('Custom value at path: firstName');
125+
});

tests/types.test-d.ts

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
55
import { expectTypeOf } from 'vitest';
66
import { z } from 'zod';
77

8-
test('lens should use output type of the Controller', () => {
8+
test('lens should use input type of the Controller', () => {
99
const schema = z.object({
1010
id: z.number().transform((val) => val.toString()),
1111
});
@@ -21,7 +21,7 @@ test('lens should use output type of the Controller', () => {
2121
});
2222

2323
const lens = useLens({ control: form.control });
24-
expectTypeOf<typeof lens>().toEqualTypeOf<Lens<Output>>();
24+
expectTypeOf<typeof lens>().toEqualTypeOf<Lens<Input>>();
2525
});
2626

2727
test('lens unwraps to the original type', () => {
@@ -214,28 +214,3 @@ test('generic assert for primitive union', () => {
214214
maybe.assert<string>();
215215
needsString(maybe); // should compile when assert works
216216
});
217-
218-
test('should be able to narrow branded types', () => {
219-
const EmailAddress = z.email().brand('EmailAddress');
220-
type EmailAddress = z.infer<typeof EmailAddress>;
221-
222-
const FormSchema = z.object({ email: EmailAddress });
223-
type FormValue = z.infer<typeof FormSchema>;
224-
const { control } = useForm({ resolver: zodResolver(FormSchema) });
225-
const lens = useLens({ control });
226-
227-
function check({ lens }: { lens: Lens<FormValue> }) {
228-
return lens;
229-
}
230-
231-
check({ lens });
232-
233-
const email = lens.focus('email');
234-
expectTypeOf<typeof email>().toEqualTypeOf<Lens<string & z.core.$brand<'EmailAddress'>>>();
235-
236-
function checkString(props: { lens: Lens<string> }) {
237-
return props;
238-
}
239-
240-
checkString({ lens: email.cast<string>() });
241-
});

0 commit comments

Comments
 (0)