Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/__tests__/e2e/next-ssr.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,8 @@ test.describe('Zero-UI Comprehensive Test Suite', () => {
await expect(themeTest).toHaveCSS('background-color', 'rgb(0, 0, 0)'); // black

// Color styling
await expect(colorTest).toHaveCSS('background-color', 'oklch(0.637 0.237 25.331)'); // red-500
await expect(colorTest).toHaveCSS('background-color', 'lab(55.4814 75.0732 48.8528)'); // red-500
await page.getByTestId('color-blue').click();
await expect(colorTest).toHaveCSS('background-color', 'oklch(0.623 0.214 259.815)'); // blue-500
await expect(colorTest).toHaveCSS('background-color', 'lab(54.1736 13.3369 -74.6839)'); // blue-500
});
});
1 change: 1 addition & 0 deletions packages/core/__tests__/fixtures/next/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
14 changes: 7 additions & 7 deletions packages/core/__tests__/fixtures/next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@
"clean": "rm -rf .next node_modules package-lock.json"
},
"dependencies": {
"next": "15.0.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.10",
"@tailwindcss/postcss": "4.2.2",
"@types/node": "24.0.0",
"@types/react": "19.1.7",
"@types/react": "19.2.14",
"eslint-plugin-react-zero-ui": "0.0.1-beta.1",
"postcss": "^8.5.5",
"tailwindcss": "^4.1.10",
"postcss": "8.5.8",
"tailwindcss": "4.2.2",
"typescript": "5.8.3"
}
}
7 changes: 4 additions & 3 deletions packages/core/__tests__/fixtures/next/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"strict": true,
"noEmit": true,
"incremental": true,
"module": "ESNext",
"esModuleInterop": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"plugins": [
{
"name": "next"
Expand All @@ -35,7 +35,8 @@
".next/**/*.d.ts",
".next/types/**/*.ts",
".zero-ui/**/*.d.ts",
"next-env.d.ts"
"next-env.d.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
Expand Down
18 changes: 9 additions & 9 deletions packages/core/__tests__/fixtures/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@
"build-and-preview": "vite build && vite preview --port 5173"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.10",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"postcss": "^8.5.5",
"tailwindcss": "^4.1.10",
"@tailwindcss/postcss": "4.2.2",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.1",
"postcss": "8.5.8",
"tailwindcss": "4.2.2",
"typescript": "~5.8.3",
"vite": "^6.3.5"
"vite": "8.0.3"
}
}
29 changes: 29 additions & 0 deletions packages/core/__tests__/unit/index.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,35 @@ test('generates body attributes file correctly', async () => {
);
});

test('warns instead of auto-initializing when project setup is missing', async () => {
await runTest(
{
'app/test.jsx': `
import { useUI } from '@react-zero-ui/core';

function Component() {
const [theme, setTheme] = useUI('theme', 'light');
return <div>Test</div>;
}
`,
},
(result) => {
assert(fs.existsSync(getAttrFile()), 'Attributes file should still be generated');
assert(!fs.existsSync('postcss.config.js'), 'PostCSS plugin should not create postcss.config.js');
assert(!fs.existsSync('postcss.config.mjs'), 'PostCSS plugin should not create postcss.config.mjs');
assert(!fs.existsSync('tsconfig.json'), 'PostCSS plugin should not patch tsconfig');

const warnings = result.warnings().map((warning) => warning.text);
assert(
warnings.some(
(text) => text.includes('Zero UI is not initialized') && text.includes('react-zero-ui')
),
'Expected a setup warning instead of auto-initialization'
);
}
);
});

test('generates body attributes file correctly when kebab-case is used', async () => {
await runTest(
{
Expand Down
58 changes: 28 additions & 30 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
'use client';
import { useRef, type RefObject } from 'react';
import { useRef } from 'react';
import { cssVar, makeSetter } from './internal.js';

type UIAction<T extends string> = T | ((prev: T) => T);

type ScopedRef = RefObject<HTMLElement | null> | (((node: HTMLElement | null) => void) & { current: HTMLElement | null });
type ScopedRef = ((node: HTMLElement | null) => void) & { current: HTMLElement | null };

interface ScopedSetterFn<T extends string = string> {
(action: UIAction<T>): void; // ← SINGLE source of truth
Expand All @@ -22,40 +22,38 @@ function useScopedUI<T extends string = string>(key: string, initialValue: T, fl
// Create a ref to hold the DOM element that will receive the data-* attributes
// This allows scoping UI state to specific elements instead of always using document.body
const scopeRef = useRef<HTMLElement | null>(null);

const setterFn = useRef(makeSetter(key, initialValue, () => scopeRef.current!, flag)).current as ScopedSetterFn<T>;

if (process.env.NODE_ENV !== 'production') {
// -- DEV-ONLY MULTIPLE REF GUARD (removed in production by modern bundlers) --
// Attach the ref to the setter function so users can write: <div ref={setterFn.ref} />
const refAttachCount = useRef(0);
// DEV: Wrap scopeRef to detect multiple attachments
const attachRef = ((node: HTMLElement | null) => {
if (node) {
refAttachCount!.current++;
if (refAttachCount!.current > 1) {
// TODO add documentation link
throw new Error(
`[useUI] Multiple ref attachments detected for key "${key}". ` +
`Each useScopedUI hook supports only one ref attachment per component. ` +
`Solution: Create separate component. and reuse.\n` +
`React Strict Mode May Cause the Ref to be attached multiple times.`
);
const refAttachCount = useRef(0);
const attachRef = useRef<ScopedRef | null>(null);

if (!attachRef.current) {
attachRef.current = ((node: HTMLElement | null) => {
if (process.env.NODE_ENV !== 'production') {
if (node) {
refAttachCount.current++;
if (refAttachCount.current > 1) {
// TODO add documentation link
throw new Error(
`[useUI] Multiple ref attachments detected for key "${key}". ` +
`Each useScopedUI hook supports only one ref attachment per component. ` +
`Solution: Create separate component. and reuse.\n` +
`React Strict Mode May Cause the Ref to be attached multiple times.`
);
}
} else {
// Handle cleanup when ref is detached
refAttachCount.current = Math.max(0, refAttachCount.current - 1);
}
} else {
// Handle cleanup when ref is detached
refAttachCount!.current = Math.max(0, refAttachCount!.current - 1);
}

scopeRef.current = node;
attachRef.current = node;
}) as ((node: HTMLElement | null) => void) & { current: HTMLElement | null };
attachRef.current = null;
(setterFn as ScopedSetterFn<T>).ref = attachRef;
} else {
// PROD: Direct ref assignment for zero overhead
setterFn.ref = scopeRef;
attachRef.current!.current = node;
}) as ScopedRef;
attachRef.current.current = null;
}

setterFn.ref = attachRef.current;

// Return tuple matching React's useState pattern: [initialValue, setter]
return [initialValue, setterFn];
}
Expand Down
60 changes: 48 additions & 12 deletions packages/core/src/postcss/index.cts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,60 @@
/**
* @type {import('postcss').PluginCreator}
*/
import { buildCss, generateAttributesFile, isZeroUiInitialized } from './helpers';
import { runZeroUiInit } from '../cli/postInstall.js';
import { processVariants } from './ast-parsing';
import { CONFIG } from '../config';
import { formatError, registerDeps, Result } from './utilities.js';

type Root = { prepend: (css: string) => void };
type Result = {
messages: { type: string; plugin: string; file: string; parent: string }[];
opts: { from: string };
prepend: (css: string) => void;
warn: (message: string, options?: { endIndex?: number; index?: number; node?: Node; plugin?: string; word?: string }) => void;
};
type RuntimeModules = {
buildCss: typeof import('./helpers.js').buildCss;
generateAttributesFile: typeof import('./helpers.js').generateAttributesFile;
isZeroUiInitialized: typeof import('./helpers.js').isZeroUiInitialized;
processVariants: typeof import('./ast-parsing.js').processVariants;
formatError: typeof import('./utilities.js').formatError;
registerDeps: typeof import('./utilities.js').registerDeps;
};

const zeroUIPlugin = 'postcss-react-zero-ui';
const warnedCwds = new Set<string>();
let runtimeModulesPromise: Promise<RuntimeModules> | null = null;

function loadRuntimeModules(): Promise<RuntimeModules> {
if (!runtimeModulesPromise) {
runtimeModulesPromise = Promise.all([import('./helpers.js'), import('./ast-parsing.js'), import('./utilities.js')]).then(
([helpers, astParsing, utilities]) => ({
buildCss: helpers.buildCss,
generateAttributesFile: helpers.generateAttributesFile,
isZeroUiInitialized: helpers.isZeroUiInitialized,
processVariants: astParsing.processVariants,
formatError: utilities.formatError,
registerDeps: utilities.registerDeps,
})
);
}

const zeroUIPlugin = CONFIG.PLUGIN_NAME;
return runtimeModulesPromise;
}

function warnIfNotInitialized(result: Result, isZeroUiInitialized: RuntimeModules['isZeroUiInitialized']) {
const cwd = process.cwd();

if (isZeroUiInitialized() || warnedCwds.has(cwd)) {
return;
}

warnedCwds.add(cwd);
result.warn('[Zero-UI] Zero UI is not initialized. Run `react-zero-ui` to patch your project config.', { plugin: zeroUIPlugin });
}

const plugin = () => {
return {
postcssPlugin: zeroUIPlugin,
async Once(root: Root, { result }: { result: Result }) {
try {
const { buildCss, generateAttributesFile, isZeroUiInitialized, processVariants, formatError, registerDeps } = await loadRuntimeModules();
const { finalVariants, initialGlobalValues, sourceFiles } = await processVariants();

const cssBlock = buildCss(finalVariants);
Expand All @@ -25,13 +64,10 @@ const plugin = () => {
/* ── register file-dependencies for HMR ─────────────────── */
registerDeps(result, zeroUIPlugin, sourceFiles, result.opts.from ?? '');

/* ── first-run bootstrap ────────────────────────────────── */
if (!isZeroUiInitialized()) {
console.log('[Zero-UI] Auto-initializing (first-time setup)…');
await runZeroUiInit();
}
warnIfNotInitialized(result, isZeroUiInitialized);
await generateAttributesFile(finalVariants, initialGlobalValues);
} catch (err: unknown) {
const { formatError, registerDeps } = await loadRuntimeModules();
const { friendly, loc } = formatError(err);
if (process.env.NODE_ENV !== 'production') {
if (loc?.file) registerDeps(result, zeroUIPlugin, [loc.file], result.opts.from ?? '');
Expand Down
Loading
Loading