skills/preact/SKILL.md
Preact 10 patterns with React-compat and Module Federation singleton setup. Trigger: When writing Preact components, hooks, types, or configuring Preact in Rsbuild/Rslib/Rstest.
npx skillsauth add Hyperxq/modular-frontend-architecture preactInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
forwardRef or any compat bridgeThis project has a strict separation between shell (smart) and ui-components (dumb).
ui-components — display onlyshellpeerDependencies — the component output bundles NOTHINGshell — smart layerui-components via props or contextui-components reach back up for dataNeed global app state? → Zustand store in shell, passed as prop to component
Need to share across subtree? → Context provider in shell, useContext in component
Need local UI state? → useState / useReducer inside the component (fine in ui-components)
Need to trigger app logic? → Callback prop passed from shell to component
jsxImportSource: preactEvery tsconfig.json in the monorepo must include:
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}
Never use "jsxImportSource": "react". This project does NOT import the React namespace.
// ✅ Hooks → preact/hooks
import { useState, useEffect, useRef, useCallback, useMemo, useReducer, useContext, createContext } from "preact/hooks";
// ✅ Core types and primitives → preact
import { h, Fragment, createRef, cloneElement } from "preact";
import type { FunctionalComponent, ComponentChildren, VNode, RefObject } from "preact";
// ✅ forwardRef, memo, lazy, Suspense → preact/compat
import { forwardRef, memo, lazy, Suspense } from "preact/compat";
// ❌ NEVER — even though react is aliased, don't import it directly
import React from "react";
import { useState } from "react";
import type { FC } from "react";
import type { FunctionalComponent, ComponentChildren } from "preact";
import { useState } from "preact/hooks";
interface CardProps {
title: string;
children: ComponentChildren;
onClose?: () => void;
}
export const Card: FunctionalComponent<CardProps> = ({ title, children, onClose }) => {
const [open, setOpen] = useState(true);
if (!open) return null;
return (
<div className="card">
<h2>{title}</h2>
<div>{children}</div>
{onClose && (
<button type="button" onClick={() => { setOpen(false); onClose(); }}>
Close
</button>
)}
</div>
);
};
import { useState, useEffect, useRef, useCallback } from "preact/hooks";
import type { RefObject } from "preact";
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// useRef with type
const inputRef: RefObject<HTMLInputElement> = useRef<HTMLInputElement>(null);
import { forwardRef } from "preact/compat";
import type { Ref } from "preact";
interface InputProps {
label: string;
value: string;
onChange: (value: string) => void;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, value, onChange }, ref) => (
<label>
{label}
<input
ref={ref}
value={value}
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
/>
</label>
)
);
import { signal, computed, effect } from "@preact/signals";
const count = signal(0);
const doubled = computed(() => count.value * 2);
effect(() => {
console.log("count changed:", count.value);
});
// In component — signal auto-subscribes on read
export const Counter: FunctionalComponent = () => (
<button type="button" onClick={() => count.value++}>
{count} × 2 = {doubled}
</button>
);
| Type | From | Usage |
|------|------|-------|
| FunctionalComponent<P> | preact | Function components (replaces React.FC) |
| ComponentChildren | preact | children prop type (replaces React.ReactNode) |
| VNode | preact | JSX element return type |
| RefObject<T> | preact | Return type of useRef<T>() |
| JSX.CSSProperties | preact | Inline style object (replaces React.CSSProperties) |
| Ref<T> | preact | Accepts both callback refs and RefObject<T> |
| ComponentType<P> | preact | Union of FC and class component types |
| React | Preact equivalent |
|-------|-------------------|
| React.FC<P> | FunctionalComponent<P> from preact |
| React.ReactNode | ComponentChildren from preact |
| React.CSSProperties | JSX.CSSProperties from preact |
| import { useState } from "react" | import { useState } from "preact/hooks" |
| import { forwardRef } from "react" | import { forwardRef } from "preact/compat" |
| class attribute | Both class and className work (compat normalizes) |
| React.createElement | h from preact (but rarely needed directly) |
| React.Fragment | Fragment from preact or <>...</> shorthand |
Preact must be configured as singleton in all MF hosts and remotes. Duplicate Preact runtimes cause hooks to silently break.
// rsbuild.config.ts / rslib.config.ts
import { pluginModuleFederation } from "@module-federation/rsbuild-plugin";
export default {
plugins: [
pluginModuleFederation({
name: "my_app",
shared: {
preact: {
singleton: true,
requiredVersion: "^10.0.0",
},
"preact/hooks": {
singleton: true,
requiredVersion: "^10.0.0",
},
"preact/compat": {
singleton: true,
requiredVersion: "^10.0.0",
},
"preact/jsx-runtime": {
singleton: true,
requiredVersion: "^10.0.0",
},
},
}),
],
};
pluginPreact() must be present in every rsbuild.config.ts, rslib.config.ts, and rstest.config.ts. Without it, JSX transform breaks and HMR won't work correctly.
// rsbuild.config.ts
import { defineConfig } from "@rsbuild/core";
import { pluginPreact } from "@rsbuild/plugin-preact";
export default defineConfig({
plugins: [pluginPreact()],
});
// rstest.config.ts
import { defineConfig } from "@rstest/core";
import { pluginPreact } from "@rsbuild/plugin-preact";
export default defineConfig({
plugins: [pluginPreact()],
// ... test config
});
bun run dev # dev server with Preact HMR
bun run test # runs rstest with pluginPreact()
bun run build # rslib/rsbuild build with Preact
bun run typecheck # tsc --noEmit — validates jsxImportSource
NEVER place a ref target element inside a <Suspense> boundary if a hook in the parent component depends on that ref in a useEffect.
When a component wraps lazy children in <Suspense>, the children don't exist in the DOM until the lazy imports resolve. But the parent's useEffect runs immediately on mount — when ref.current is still null. Since ref is a stable object identity, the effect never re-runs, and any hook that attaches listeners or observers (useSwipe, useFocusTrap, useClickOutside, IntersectionObserver, ResizeObserver, etc.) silently fails with zero errors.
// ❌ BAD — ref is null when useEffect runs, listeners never attached
const MyComponent: FunctionalComponent = () => {
const contentRef = useRef<HTMLDivElement>(null);
useSwipe(contentRef, { onSwipeLeft: goNext }); // effect runs, ref.current is null → silent no-op
return (
<Suspense fallback={<Skeleton />}>
<LazyChild>
<div ref={contentRef}>content</div> {/* doesn't exist until lazy resolves */}
</LazyChild>
</Suspense>
);
};
// ✅ GOOD — ref target is outside Suspense, available immediately
const MyComponent: FunctionalComponent = () => {
const contentRef = useRef<HTMLDivElement>(null);
useSwipe(contentRef, { onSwipeLeft: goNext }); // effect runs, ref.current exists ✓
return (
<div ref={contentRef}> {/* exists on mount, events from children bubble up */}
<Suspense fallback={<Skeleton />}>
<LazyChild>content</LazyChild>
</Suspense>
</div>
);
};
Key points:
"react" — fails silently or throws; always use "preact/hooks"pluginPreact() in rstest — tests can't parse JSXsingleton: trueReact.FC — use FunctionalComponent<P> from preact insteadjsxImportSource: "react" in any tsconfig — breaks the entire JSX transform for that package<Suspense> — ref is null when useEffect runs; listeners never attached (see section above)development
Rstest patterns for Rspack-native unit testing with Preact. Trigger: When writing tests with @rstest/core, testing-library/preact, or configuring rstest.config.ts.
tools
Rspack bundler patterns for Rsbuild/Rslib config customization. Trigger: When customizing rspack config via tools.rspack, adding plugins, aliases, or Module Federation setup.
tools
Rslib library build tool patterns for Rspack-based component libraries. Trigger: When configuring rslib.config.ts, library builds, Module Federation remotes, or dynamic entry discovery.
tools
# Skill: playwright (project-local) Extends the global Playwright skill with project-specific setup, browser install, and MF dev server orchestration for this monorepo. --- ## Browser Installation in AI Agents (OpenCode / Claude) The MCP Playwright server looks for `chrome` at `/opt/google/chrome/chrome` by default. That binary is **not available** in this environment. ### Fix 1 — Configure MCP to use chromium (preferred, one-time) In `~/.config/opencode/opencode.json`, add `--browser chro