Skip to content

Commit d1dbe32

Browse files
authored
Fix PostCSS packaging and fixture compatibility (#27)
* Fix PostCSS packaging and upgrade test fixtures * Fix Type error for importing a ref
1 parent ac2df0a commit d1dbe32

File tree

9 files changed

+520
-97
lines changed

9 files changed

+520
-97
lines changed

packages/core/__tests__/e2e/next-ssr.spec.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,8 +251,8 @@ test.describe('Zero-UI Comprehensive Test Suite', () => {
251251
await expect(themeTest).toHaveCSS('background-color', 'rgb(0, 0, 0)'); // black
252252

253253
// Color styling
254-
await expect(colorTest).toHaveCSS('background-color', 'oklch(0.637 0.237 25.331)'); // red-500
254+
await expect(colorTest).toHaveCSS('background-color', 'lab(55.4814 75.0732 48.8528)'); // red-500
255255
await page.getByTestId('color-blue').click();
256-
await expect(colorTest).toHaveCSS('background-color', 'oklch(0.623 0.214 259.815)'); // blue-500
256+
await expect(colorTest).toHaveCSS('background-color', 'lab(54.1736 13.3369 -74.6839)'); // blue-500
257257
});
258258
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3+
import "./.next/types/routes.d.ts";
34

45
// NOTE: This file should not be edited
56
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

packages/core/__tests__/fixtures/next/package.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@
88
"clean": "rm -rf .next node_modules package-lock.json"
99
},
1010
"dependencies": {
11-
"next": "15.0.7",
12-
"react": "^18.2.0",
13-
"react-dom": "^18.2.0"
11+
"next": "16.2.2",
12+
"react": "19.2.4",
13+
"react-dom": "19.2.4"
1414
},
1515
"devDependencies": {
16-
"@tailwindcss/postcss": "^4.1.10",
16+
"@tailwindcss/postcss": "4.2.2",
1717
"@types/node": "24.0.0",
18-
"@types/react": "19.1.7",
18+
"@types/react": "19.2.14",
1919
"eslint-plugin-react-zero-ui": "0.0.1-beta.1",
20-
"postcss": "^8.5.5",
21-
"tailwindcss": "^4.1.10",
20+
"postcss": "8.5.8",
21+
"tailwindcss": "4.2.2",
2222
"typescript": "5.8.3"
2323
}
2424
}

packages/core/__tests__/fixtures/next/tsconfig.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@
77
],
88
"allowJs": true,
99
"skipLibCheck": true,
10-
"strict": false,
10+
"strict": true,
1111
"noEmit": true,
1212
"incremental": true,
1313
"module": "ESNext",
1414
"esModuleInterop": true,
1515
"moduleResolution": "bundler",
1616
"resolveJsonModule": true,
1717
"isolatedModules": true,
18-
"jsx": "preserve",
18+
"jsx": "react-jsx",
1919
"plugins": [
2020
{
2121
"name": "next"
@@ -35,7 +35,8 @@
3535
".next/**/*.d.ts",
3636
".next/types/**/*.ts",
3737
".zero-ui/**/*.d.ts",
38-
"next-env.d.ts"
38+
"next-env.d.ts",
39+
".next/dev/types/**/*.ts"
3940
],
4041
"exclude": [
4142
"node_modules"

packages/core/__tests__/fixtures/vite/package.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,17 @@
99
"build-and-preview": "vite build && vite preview --port 5173"
1010
},
1111
"dependencies": {
12-
"react": "^19.1.0",
13-
"react-dom": "^19.1.0"
12+
"react": "19.2.4",
13+
"react-dom": "19.2.4"
1414
},
1515
"devDependencies": {
16-
"@tailwindcss/postcss": "^4.1.10",
17-
"@types/react": "^19.1.2",
18-
"@types/react-dom": "^19.1.2",
19-
"@vitejs/plugin-react": "^4.4.1",
20-
"postcss": "^8.5.5",
21-
"tailwindcss": "^4.1.10",
16+
"@tailwindcss/postcss": "4.2.2",
17+
"@types/react": "19.2.14",
18+
"@types/react-dom": "19.2.3",
19+
"@vitejs/plugin-react": "6.0.1",
20+
"postcss": "8.5.8",
21+
"tailwindcss": "4.2.2",
2222
"typescript": "~5.8.3",
23-
"vite": "^6.3.5"
23+
"vite": "8.0.3"
2424
}
2525
}

packages/core/__tests__/unit/index.test.cjs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,35 @@ test('generates body attributes file correctly', async () => {
9696
);
9797
});
9898

99+
test('warns instead of auto-initializing when project setup is missing', async () => {
100+
await runTest(
101+
{
102+
'app/test.jsx': `
103+
import { useUI } from '@react-zero-ui/core';
104+
105+
function Component() {
106+
const [theme, setTheme] = useUI('theme', 'light');
107+
return <div>Test</div>;
108+
}
109+
`,
110+
},
111+
(result) => {
112+
assert(fs.existsSync(getAttrFile()), 'Attributes file should still be generated');
113+
assert(!fs.existsSync('postcss.config.js'), 'PostCSS plugin should not create postcss.config.js');
114+
assert(!fs.existsSync('postcss.config.mjs'), 'PostCSS plugin should not create postcss.config.mjs');
115+
assert(!fs.existsSync('tsconfig.json'), 'PostCSS plugin should not patch tsconfig');
116+
117+
const warnings = result.warnings().map((warning) => warning.text);
118+
assert(
119+
warnings.some(
120+
(text) => text.includes('Zero UI is not initialized') && text.includes('react-zero-ui')
121+
),
122+
'Expected a setup warning instead of auto-initialization'
123+
);
124+
}
125+
);
126+
});
127+
99128
test('generates body attributes file correctly when kebab-case is used', async () => {
100129
await runTest(
101130
{

packages/core/src/index.ts

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
'use client';
2-
import { useRef, type RefObject } from 'react';
2+
import { useRef } from 'react';
33
import { cssVar, makeSetter } from './internal.js';
44

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

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

99
interface ScopedSetterFn<T extends string = string> {
1010
(action: UIAction<T>): void; // ← SINGLE source of truth
@@ -22,40 +22,38 @@ function useScopedUI<T extends string = string>(key: string, initialValue: T, fl
2222
// Create a ref to hold the DOM element that will receive the data-* attributes
2323
// This allows scoping UI state to specific elements instead of always using document.body
2424
const scopeRef = useRef<HTMLElement | null>(null);
25-
2625
const setterFn = useRef(makeSetter(key, initialValue, () => scopeRef.current!, flag)).current as ScopedSetterFn<T>;
27-
28-
if (process.env.NODE_ENV !== 'production') {
29-
// -- DEV-ONLY MULTIPLE REF GUARD (removed in production by modern bundlers) --
30-
// Attach the ref to the setter function so users can write: <div ref={setterFn.ref} />
31-
const refAttachCount = useRef(0);
32-
// DEV: Wrap scopeRef to detect multiple attachments
33-
const attachRef = ((node: HTMLElement | null) => {
34-
if (node) {
35-
refAttachCount!.current++;
36-
if (refAttachCount!.current > 1) {
37-
// TODO add documentation link
38-
throw new Error(
39-
`[useUI] Multiple ref attachments detected for key "${key}". ` +
40-
`Each useScopedUI hook supports only one ref attachment per component. ` +
41-
`Solution: Create separate component. and reuse.\n` +
42-
`React Strict Mode May Cause the Ref to be attached multiple times.`
43-
);
26+
const refAttachCount = useRef(0);
27+
const attachRef = useRef<ScopedRef | null>(null);
28+
29+
if (!attachRef.current) {
30+
attachRef.current = ((node: HTMLElement | null) => {
31+
if (process.env.NODE_ENV !== 'production') {
32+
if (node) {
33+
refAttachCount.current++;
34+
if (refAttachCount.current > 1) {
35+
// TODO add documentation link
36+
throw new Error(
37+
`[useUI] Multiple ref attachments detected for key "${key}". ` +
38+
`Each useScopedUI hook supports only one ref attachment per component. ` +
39+
`Solution: Create separate component. and reuse.\n` +
40+
`React Strict Mode May Cause the Ref to be attached multiple times.`
41+
);
42+
}
43+
} else {
44+
// Handle cleanup when ref is detached
45+
refAttachCount.current = Math.max(0, refAttachCount.current - 1);
4446
}
45-
} else {
46-
// Handle cleanup when ref is detached
47-
refAttachCount!.current = Math.max(0, refAttachCount!.current - 1);
4847
}
48+
4949
scopeRef.current = node;
50-
attachRef.current = node;
51-
}) as ((node: HTMLElement | null) => void) & { current: HTMLElement | null };
52-
attachRef.current = null;
53-
(setterFn as ScopedSetterFn<T>).ref = attachRef;
54-
} else {
55-
// PROD: Direct ref assignment for zero overhead
56-
setterFn.ref = scopeRef;
50+
attachRef.current!.current = node;
51+
}) as ScopedRef;
52+
attachRef.current.current = null;
5753
}
5854

55+
setterFn.ref = attachRef.current;
56+
5957
// Return tuple matching React's useState pattern: [initialValue, setter]
6058
return [initialValue, setterFn];
6159
}

packages/core/src/postcss/index.cts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,60 @@
22
/**
33
* @type {import('postcss').PluginCreator}
44
*/
5-
import { buildCss, generateAttributesFile, isZeroUiInitialized } from './helpers';
6-
import { runZeroUiInit } from '../cli/postInstall.js';
7-
import { processVariants } from './ast-parsing';
8-
import { CONFIG } from '../config';
9-
import { formatError, registerDeps, Result } from './utilities.js';
10-
115
type Root = { prepend: (css: string) => void };
6+
type Result = {
7+
messages: { type: string; plugin: string; file: string; parent: string }[];
8+
opts: { from: string };
9+
prepend: (css: string) => void;
10+
warn: (message: string, options?: { endIndex?: number; index?: number; node?: Node; plugin?: string; word?: string }) => void;
11+
};
12+
type RuntimeModules = {
13+
buildCss: typeof import('./helpers.js').buildCss;
14+
generateAttributesFile: typeof import('./helpers.js').generateAttributesFile;
15+
isZeroUiInitialized: typeof import('./helpers.js').isZeroUiInitialized;
16+
processVariants: typeof import('./ast-parsing.js').processVariants;
17+
formatError: typeof import('./utilities.js').formatError;
18+
registerDeps: typeof import('./utilities.js').registerDeps;
19+
};
20+
21+
const zeroUIPlugin = 'postcss-react-zero-ui';
22+
const warnedCwds = new Set<string>();
23+
let runtimeModulesPromise: Promise<RuntimeModules> | null = null;
24+
25+
function loadRuntimeModules(): Promise<RuntimeModules> {
26+
if (!runtimeModulesPromise) {
27+
runtimeModulesPromise = Promise.all([import('./helpers.js'), import('./ast-parsing.js'), import('./utilities.js')]).then(
28+
([helpers, astParsing, utilities]) => ({
29+
buildCss: helpers.buildCss,
30+
generateAttributesFile: helpers.generateAttributesFile,
31+
isZeroUiInitialized: helpers.isZeroUiInitialized,
32+
processVariants: astParsing.processVariants,
33+
formatError: utilities.formatError,
34+
registerDeps: utilities.registerDeps,
35+
})
36+
);
37+
}
1238

13-
const zeroUIPlugin = CONFIG.PLUGIN_NAME;
39+
return runtimeModulesPromise;
40+
}
41+
42+
function warnIfNotInitialized(result: Result, isZeroUiInitialized: RuntimeModules['isZeroUiInitialized']) {
43+
const cwd = process.cwd();
44+
45+
if (isZeroUiInitialized() || warnedCwds.has(cwd)) {
46+
return;
47+
}
48+
49+
warnedCwds.add(cwd);
50+
result.warn('[Zero-UI] Zero UI is not initialized. Run `react-zero-ui` to patch your project config.', { plugin: zeroUIPlugin });
51+
}
1452

1553
const plugin = () => {
1654
return {
1755
postcssPlugin: zeroUIPlugin,
1856
async Once(root: Root, { result }: { result: Result }) {
1957
try {
58+
const { buildCss, generateAttributesFile, isZeroUiInitialized, processVariants, formatError, registerDeps } = await loadRuntimeModules();
2059
const { finalVariants, initialGlobalValues, sourceFiles } = await processVariants();
2160

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

28-
/* ── first-run bootstrap ────────────────────────────────── */
29-
if (!isZeroUiInitialized()) {
30-
console.log('[Zero-UI] Auto-initializing (first-time setup)…');
31-
await runZeroUiInit();
32-
}
67+
warnIfNotInitialized(result, isZeroUiInitialized);
3368
await generateAttributesFile(finalVariants, initialGlobalValues);
3469
} catch (err: unknown) {
70+
const { formatError, registerDeps } = await loadRuntimeModules();
3571
const { friendly, loc } = formatError(err);
3672
if (process.env.NODE_ENV !== 'production') {
3773
if (loc?.file) registerDeps(result, zeroUIPlugin, [loc.file], result.opts.from ?? '');

0 commit comments

Comments
 (0)