Skip to content

Commit 01804a8

Browse files
timphandevclaude
andcommitted
fix(native-recipe): accept slot styles whose slot names are not known in base and compound variants
A slot style recipe accepts variants typed as SlotStyleRecipeVariants, whose slot names are not known at compile time, but rejected such styles elsewhere: a `base` typed as SlotStyles<string> failed, since TypeScript removed the type from both sides of `Base & SlotStyles<string>` before inferring Base, and the compound variants' check lacked the guard that the variants' check has, so it rejected every slot. `Base` is now constrained to slot styles instead of intersected with them, as it already was in createSlotStyleRecipe and now is in the themed creator too, and the compound check accepts styles whose slot names are not known. Arrays, numbers, and undeclared slots are still rejected. Where I looked: the base, variant, and compound checks of createSlotStyleRecipe and the themed creator, createStyleRecipe (a base typed as NativeStyle is accepted), sva of class-recipe, and the engine (neither infers its base). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 46568ff commit 01804a8

6 files changed

Lines changed: 73 additions & 16 deletions

File tree

‎api/native-recipe.d.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ComposableKindRecipe, ComposableKindRecipe as ComposableKindRecipe$1, ComposableKindSlotRecipe, ComposableKindSlotRecipe as ComposableKindSlotRecipe$1, ComposedDefaultedName, ComposedSlot, ComposedSlot as ComposedSlot$1, ComposedVariants, ComposedVariants as ComposedVariants$1, CompoundCondition, DefaultVariants, KindRecipe, KindRecipe as KindRecipe$1, KindVariants, RecipeComposition, RecipeComposition as RecipeComposition$1, RecipeFunction, VariantOption, VariantSelection, VariantsOf as VariantsOf$1 } from "@lynstack/recipe";
1+
import { ComposableKindRecipe, ComposableKindRecipe as ComposableKindRecipe$1, ComposableKindSlotRecipe, ComposableKindSlotRecipe as ComposableKindSlotRecipe$1, ComposedDefaultedName, ComposedSlot, ComposedVariants, ComposedVariants as ComposedVariants$1, CompoundCondition, DefaultVariants, KindRecipe, KindRecipe as KindRecipe$1, KindVariants, RecipeComposition, RecipeComposition as RecipeComposition$1, RecipeFunction, VariantOption, VariantSelection, VariantsOf as VariantsOf$1 } from "@lynstack/recipe";
22
import { ImageStyle, TextStyle, ViewStyle } from "react-native";
33
//#region src/types.d.ts
44
type NativeStyle = ViewStyle | TextStyle | ImageStyle;
@@ -16,7 +16,7 @@ type SlotStyles<Slot extends string> = Readonly<Partial<Record<Slot, NativeStyle
1616
type NoUnknownSlotStyles<Styles, Slot extends string, Inherited extends string = never> = string extends keyof Styles ? unknown : { readonly [Name in keyof Styles]: Name extends Slot ? NoUnknownProperties<NonNullable<Styles[Name]>> : Name extends Inherited ? NoUnknownProperties<NonNullable<Styles[Name]>> : never; };
1717
type NoUnknownVariantStyles<Variants, Slot extends string, Inherited extends string = never> = { readonly [Name in keyof Variants]: { readonly [Option in keyof Variants[Name]]: NoUnknownSlotStyles<Variants[Name][Option], Slot, Inherited>; }; };
1818
type InheritedSlot<Composed extends readonly ComposableKindSlotRecipe$1<NativeStyle>[]> = NonNullable<Composed[number]["~composition"]>["slots"][number];
19-
type NoUnknownCompoundStyles<Styles, Slot extends string, Inherited extends string = never> = Readonly<Partial<Record<Exclude<Exclude<KeyOfEach<Styles>, Slot>, Inherited>, never>>> & { readonly [Name in Slot | Inherited]?: NoUnknownProperties<DeclaredStyle$1<Styles, Name>>; };
19+
type NoUnknownCompoundStyles<Styles, Slot extends string, Inherited extends string = never> = string extends KeyOfEach<Styles> ? SlotStyles<string> : Readonly<Partial<Record<Exclude<Exclude<KeyOfEach<Styles>, Slot>, Inherited>, never>>> & { readonly [Name in Slot | Inherited]?: NoUnknownProperties<DeclaredStyle$1<Styles, Name>>; };
2020
type DeclaredStyle$1<Styles, Slot> = Styles extends unknown ? Slot extends keyof Styles ? Exclude<Styles[Slot], undefined> : never : never;
2121
type DeclaredSlotStyles<Variants, Base, Compounds, Composed> = Base | OptionValue<Variants> | CompoundStyles<Compounds> | ComposedStyle<Composed>;
2222
type CompoundStyles<Compounds> = Compounds extends readonly (infer Compound)[] ? Compound extends {
@@ -37,7 +37,7 @@ interface SlotStyleCompoundVariant<Variants, Styles = SlotStyles<string>> {
3737
interface SlotStyleRecipeConfig<Slot extends string, Variants, Base, Compounds, DefaultedName extends keyof ComposedVariants$1<Composed, Variants>, Composed extends readonly ComposableKindSlotRecipe$1<NativeStyle>[] = readonly []> {
3838
readonly composes?: Composed | undefined;
3939
readonly slots: readonly Slot[];
40-
readonly base?: (Base & SlotStyles<string> & NoUnknownSlotStyles<Base, NoInfer<Slot>, InheritedSlot<Composed>>) | undefined;
40+
readonly base?: (Base & NoUnknownSlotStyles<Base, NoInfer<Slot>, InheritedSlot<Composed>>) | undefined;
4141
readonly variants: Variants & SlotStyleRecipeVariants & NoUnknownVariantStyles<Variants, NoInfer<Slot>, InheritedSlot<Composed>>;
4242
readonly compoundVariants?: (Compounds & readonly SlotStyleCompoundVariant<NoInfer<ComposedVariants$1<Composed, Variants>>, NoUnknownCompoundStyles<CompoundStyles<Compounds>, NoInfer<Slot>, InheritedSlot<Composed>>>[]) | undefined;
4343
readonly defaultVariants?: DefaultVariants<ComposedVariants$1<Composed, Variants>, DefaultedName> | undefined;
@@ -104,7 +104,7 @@ type ThemedRecipe<Theme, Props, Result, Composition = unknown> = ThemedRecipeFun
104104
type ComposedThemedRecipe<Theme, Variants, DefaultedName extends keyof Variants, Result, Slots> = ThemedRecipe<Theme, VariantSelection<Variants, DefaultedName>, Result, RecipeComposition$1<Variants, DefaultedName, NativeStyle, Slots>>;
105105
interface ThemedRecipeCreators<Theme extends object> {
106106
readonly createStyleRecipe: <const Variants, const Base = never, const Compounds = readonly [], const DefaultedName extends keyof ComposedVariants$1<Composed, Variants> = never, const Composed extends readonly ComposableKindRecipe$1<NativeStyle>[] = readonly []>(config: (theme: Theme) => StyleRecipeConfig<Variants, Base, Compounds, DefaultedName, Composed>) => ComposedThemedRecipe<Theme, ComposedVariants$1<Composed, Variants>, NoInfer<DefaultedName> | InheritedDefaultedName<Composed, Variants>, RecipeStyle<Variants, Base, Compounds, Composed>, undefined>;
107-
readonly createSlotStyleRecipe: <const Slot extends string, const Variants, const Base = never, const Compounds = readonly [], const DefaultedName extends keyof ComposedVariants$1<Composed, Variants> = never, const Composed extends readonly ComposableKindSlotRecipe$1<NativeStyle>[] = readonly []>(config: (theme: Theme) => SlotStyleRecipeConfig<Slot, Variants, Base, Compounds, DefaultedName, Composed>) => ComposedThemedRecipe<Theme, ComposedVariants$1<Composed, Variants>, NoInfer<DefaultedName> | InheritedDefaultedName<Composed, Variants>, RecipeSlotStyles<Slot | Exclude<Composed[number]["~composition"], undefined>["slots"][number], Variants, Base, Compounds, Composed>, readonly (Slot | Exclude<Composed[number]["~composition"], undefined>["slots"][number])[]>;
107+
readonly createSlotStyleRecipe: <const Slot extends string, const Variants, const Base extends SlotStyles<string> = never, const Compounds = readonly [], const DefaultedName extends keyof ComposedVariants$1<Composed, Variants> = never, const Composed extends readonly ComposableKindSlotRecipe$1<NativeStyle>[] = readonly []>(config: (theme: Theme) => SlotStyleRecipeConfig<Slot, Variants, Base, Compounds, DefaultedName, Composed>) => ComposedThemedRecipe<Theme, ComposedVariants$1<Composed, Variants>, NoInfer<DefaultedName> | InheritedDefaultedName<Composed, Variants>, RecipeSlotStyles<Slot | Exclude<Composed[number]["~composition"], undefined>["slots"][number], Variants, Base, Compounds, Composed>, readonly (Slot | Exclude<Composed[number]["~composition"], undefined>["slots"][number])[]>;
108108
}
109109
declare function createThemedRecipes<Theme extends object>(): ThemedRecipeCreators<Theme>;
110110
//#endregion

‎packages/native-recipe/CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ published on npm and as a
77

88
## Unreleased
99

10+
- A slot style recipe, plain or themed, accepts styles whose slot names
11+
are not known at compile time, such as a `base` typed
12+
`SlotStyles<string>`, in `base` and in compound variants, as it already
13+
did in its variants. They were rejected as "not assignable to type
14+
'undefined'".
1015
- A function generic over the slot recipe that a slot style recipe
1116
composes, plain or themed, can name its own slots.
1217
- The recipe creators warn, once, with `console.warn`, about a default, a

‎packages/native-recipe/src/slot-style-recipe.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,7 @@ interface SlotStyleRecipeConfig<
8282
readonly slots: readonly Slot[];
8383
/** The style of each slot whatever the variants. */
8484
readonly base?:
85-
| (Base &
86-
SlotStyles<string> &
87-
NoUnknownSlotStyles<Base, NoInfer<Slot>, InheritedSlot<Composed>>)
85+
| (Base & NoUnknownSlotStyles<Base, NoInfer<Slot>, InheritedSlot<Composed>>)
8886
| undefined;
8987
/** For each variant name, the style of each slot for each of its options. */
9088
readonly variants: Variants &

‎packages/native-recipe/src/themed-recipes.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
InheritedDefaultedName,
1111
NativeStyle,
1212
RecipeSlotStyles,
13+
SlotStyles,
1314
VariantSelection,
1415
} from "./types.js";
1516
import type {
@@ -145,7 +146,7 @@ interface ThemedRecipeCreators<Theme extends object> {
145146
readonly createSlotStyleRecipe: <
146147
const Slot extends string,
147148
const Variants,
148-
const Base = never,
149+
const Base extends SlotStyles<string> = never,
149150
const Compounds = readonly [],
150151
const DefaultedName extends keyof ComposedVariants<Composed, Variants> =
151152
never,

‎packages/native-recipe/src/types.ts‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,19 +123,25 @@ type InheritedSlot<
123123

124124
/**
125125
* Rejects the slots and style properties of the compound variants' styles,
126-
* given as a union, that the slot recipe does not have.
126+
* given as a union, that the slot recipe does not have, unless the slot
127+
* names of the styles are not known at compile time.
127128
*/
128129
type NoUnknownCompoundStyles<
129130
Styles,
130131
Slot extends string,
131132
Inherited extends string = never,
132-
> = Readonly<
133-
Partial<Record<Exclude<Exclude<KeyOfEach<Styles>, Slot>, Inherited>, never>>
134-
> & {
135-
readonly [Name in Slot | Inherited]?: NoUnknownProperties<
136-
DeclaredStyle<Styles, Name>
137-
>;
138-
};
133+
> =
134+
string extends KeyOfEach<Styles>
135+
? SlotStyles<string>
136+
: Readonly<
137+
Partial<
138+
Record<Exclude<Exclude<KeyOfEach<Styles>, Slot>, Inherited>, never>
139+
>
140+
> & {
141+
readonly [Name in Slot | Inherited]?: NoUnknownProperties<
142+
DeclaredStyle<Styles, Name>
143+
>;
144+
};
139145

140146
/** Every style that a slot recipe's config declares for `Slot`, as a union. */
141147
type DeclaredStyle<Styles, Slot> = Styles extends unknown
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import type { SlotStyles } from "./types.js";
4+
import { createSlotStyleRecipe } from "./slot-style-recipe.js";
5+
import { createThemedRecipes } from "./themed-recipes.js";
6+
7+
const wide: SlotStyles<string> = { root: { gap: 4 } };
8+
9+
describe("a slot style recipe given styles whose slot names are not known", () => {
10+
it("accepts them as its base", () => {
11+
const recipe = createSlotStyleRecipe({
12+
slots: ["root", "label"],
13+
base: wide,
14+
variants: {},
15+
});
16+
17+
expect(recipe()).toStrictEqual({ root: { gap: 4 }, label: {} });
18+
});
19+
20+
it("accepts them in a compound variant", () => {
21+
const recipe = createSlotStyleRecipe({
22+
slots: ["root", "label"],
23+
variants: { size: { sm: {} } },
24+
compoundVariants: [{ variants: { size: "sm" }, styles: wide }],
25+
});
26+
27+
expect(recipe({ size: "sm" })).toStrictEqual({
28+
root: { gap: 4 },
29+
label: {},
30+
});
31+
});
32+
33+
it("accepts them in a themed recipe", () => {
34+
const themed = createThemedRecipes<{ readonly gap: number }>();
35+
const recipe = themed.createSlotStyleRecipe(() => ({
36+
slots: ["root", "label"],
37+
base: wide,
38+
variants: { size: { sm: {} } },
39+
compoundVariants: [{ variants: { size: "sm" }, styles: wide }],
40+
}));
41+
42+
expect(recipe({ gap: 4 }, { size: "sm" })).toStrictEqual({
43+
root: { gap: 4 },
44+
label: {},
45+
});
46+
});
47+
});

0 commit comments

Comments
 (0)