Skip to content

Commit 2ae63aa

Browse files
qnbsclaude
andauthored
fix(storybook): wrap story/decorator hooks in components (DeepSource JS-0820) (#231)
Both rules-of-hooks violations were Storybook patterns where hooks ran in a story `render` callback / a decorator function — valid at Storybook runtime (it renders them as components) but flagged by the react-hooks rule because the function isn't a capitalized component. Fixed honestly (the recommended Storybook pattern) rather than suppressed: - stories/Tabs.stories.tsx: `useState` extracted into a `WithPanelsDemo` component; the story render returns `<WithPanelsDemo />`. - .storybook/preview.tsx: the theme `useEffect` extracted into a `ThemeWrapper` component (theme/appearance passed as props); `withTheme` decorator renders `<ThemeWrapper>…<Story/></ThemeWrapper>`. Rendered output unchanged. lint + typecheck green. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c7c5752 commit 2ae63aa

2 files changed

Lines changed: 55 additions & 36 deletions

File tree

.storybook/preview.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@ import '../index.css';
44

55
const APPEARANCE_CLASSES = ['appearance-sepia', 'appearance-fantasy', 'appearance-romance'];
66

7-
const withTheme: DecoratorFunction = (Story, context) => {
7+
// QNBS-v3: the theme effect lives in a component, not the decorator callback (DeepSource JS-0820 /
8+
// rules-of-hooks). Storybook renders ThemeWrapper as a component, so useEffect is valid here.
9+
const ThemeWrapper = ({
10+
theme,
11+
appearance,
12+
children,
13+
}: {
14+
theme: string;
15+
appearance: string;
16+
children: React.ReactNode;
17+
}) => {
818
useEffect(() => {
9-
const theme = context.globals.theme ?? 'dark';
1019
document.body.classList.remove('light-theme', 'dark-theme');
1120
document.body.classList.add(`${theme}-theme`);
1221

13-
const appearance = context.globals.appearance ?? 'default';
1422
document.body.classList.remove(...APPEARANCE_CLASSES);
1523
if (appearance === 'sepia') document.body.classList.add('appearance-sepia');
1624
if (appearance === 'fantasy') document.body.classList.add('appearance-fantasy');
@@ -19,15 +27,22 @@ const withTheme: DecoratorFunction = (Story, context) => {
1927
return () => {
2028
document.body.classList.remove('light-theme', 'dark-theme', ...APPEARANCE_CLASSES);
2129
};
22-
}, [context.globals.theme, context.globals.appearance]);
30+
}, [theme, appearance]);
2331

2432
return (
25-
<div className="min-h-screen p-6 bg-sc-surface-base text-sc-text-primary">
26-
<Story />
27-
</div>
33+
<div className="min-h-screen p-6 bg-sc-surface-base text-sc-text-primary">{children}</div>
2834
);
2935
};
3036

37+
const withTheme: DecoratorFunction = (Story, context) => (
38+
<ThemeWrapper
39+
theme={context.globals.theme ?? 'dark'}
40+
appearance={context.globals.appearance ?? 'default'}
41+
>
42+
<Story />
43+
</ThemeWrapper>
44+
);
45+
3146
const preview: Preview = {
3247
decorators: [withTheme],
3348
globalTypes: {

stories/Tabs.stories.tsx

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -56,34 +56,38 @@ export const Underline: Story = {
5656
render: () => <TabsDemo variant="underline" />,
5757
};
5858

59+
// QNBS-v3: hooks belong in a component, not a story render callback (DeepSource JS-0820 /
60+
// rules-of-hooks). Storybook renders this as a component, so useState is valid here.
61+
const WithPanelsDemo = () => {
62+
const [activeTab, setActiveTab] = useState('outline');
63+
const groupId = 'story-tabs';
64+
return (
65+
<div className="space-y-4">
66+
<Tabs
67+
tabs={TABS}
68+
activeTab={activeTab}
69+
onChange={setActiveTab}
70+
ariaLabel="Document sections"
71+
/>
72+
<TabPanel tabId="outline" activeTab={activeTab} groupId={groupId}>
73+
<div className="p-4 rounded-sc-lg bg-[var(--sc-surface-raised)] text-[var(--sc-text-primary)]">
74+
Outline content
75+
</div>
76+
</TabPanel>
77+
<TabPanel tabId="manuscript" activeTab={activeTab} groupId={groupId}>
78+
<div className="p-4 rounded-sc-lg bg-[var(--sc-surface-raised)] text-[var(--sc-text-primary)]">
79+
Manuscript content
80+
</div>
81+
</TabPanel>
82+
<TabPanel tabId="notes" activeTab={activeTab} groupId={groupId}>
83+
<div className="p-4 rounded-sc-lg bg-[var(--sc-surface-raised)] text-[var(--sc-text-primary)]">
84+
Notes content
85+
</div>
86+
</TabPanel>
87+
</div>
88+
);
89+
};
90+
5991
export const WithPanels: Story = {
60-
render: () => {
61-
const [activeTab, setActiveTab] = useState('outline');
62-
const groupId = 'story-tabs';
63-
return (
64-
<div className="space-y-4">
65-
<Tabs
66-
tabs={TABS}
67-
activeTab={activeTab}
68-
onChange={setActiveTab}
69-
ariaLabel="Document sections"
70-
/>
71-
<TabPanel tabId="outline" activeTab={activeTab} groupId={groupId}>
72-
<div className="p-4 rounded-sc-lg bg-[var(--sc-surface-raised)] text-[var(--sc-text-primary)]">
73-
Outline content
74-
</div>
75-
</TabPanel>
76-
<TabPanel tabId="manuscript" activeTab={activeTab} groupId={groupId}>
77-
<div className="p-4 rounded-sc-lg bg-[var(--sc-surface-raised)] text-[var(--sc-text-primary)]">
78-
Manuscript content
79-
</div>
80-
</TabPanel>
81-
<TabPanel tabId="notes" activeTab={activeTab} groupId={groupId}>
82-
<div className="p-4 rounded-sc-lg bg-[var(--sc-surface-raised)] text-[var(--sc-text-primary)]">
83-
Notes content
84-
</div>
85-
</TabPanel>
86-
</div>
87-
);
88-
},
92+
render: () => <WithPanelsDemo />,
8993
};

0 commit comments

Comments
 (0)