From 857197ba93eabda4b85cd8fa52205d2779425276 Mon Sep 17 00:00:00 2001 From: Austin1serb Date: Fri, 1 Aug 2025 17:37:04 -0700 Subject: [PATCH] added support to toggle css variables without re-renders --- examples/demo/.zero-ui/index.ts | 1 + examples/demo/.zero-ui/init-zero-ui.ts | 38 +++++++------------ .../demo/src/app/zero-ui-ssr/Dashboard.tsx | 5 ++- package.json | 2 +- packages/core/__tests__/e2e/next.spec.js | 10 +++++ .../fixtures/next/.zero-ui/attributes.d.ts | 3 ++ .../fixtures/next/.zero-ui/attributes.js | 1 + .../fixtures/next/app/CssVarDemo.tsx | 34 +++++++++++++++++ .../core/__tests__/fixtures/next/app/page.tsx | 29 +++++++++++++- packages/core/src/experimental/index.ts | 0 packages/core/src/index.ts | 13 ++++--- packages/core/src/internal.ts | 35 +++++++---------- .../src/rules/require-data-attr.ts | 2 +- 13 files changed, 116 insertions(+), 57 deletions(-) create mode 100644 examples/demo/.zero-ui/index.ts create mode 100644 packages/core/__tests__/fixtures/next/app/CssVarDemo.tsx create mode 100644 packages/core/src/experimental/index.ts diff --git a/examples/demo/.zero-ui/index.ts b/examples/demo/.zero-ui/index.ts new file mode 100644 index 0000000..105dace --- /dev/null +++ b/examples/demo/.zero-ui/index.ts @@ -0,0 +1 @@ +export const zeroSSR = { onClick: (key: string, vals: [...V]) => ({ 'data-ui': `cycle:${key}(${vals.join(',')})` }) as const }; diff --git a/examples/demo/.zero-ui/init-zero-ui.ts b/examples/demo/.zero-ui/init-zero-ui.ts index 9708199..5bf0023 100644 --- a/examples/demo/.zero-ui/init-zero-ui.ts +++ b/examples/demo/.zero-ui/init-zero-ui.ts @@ -1,38 +1,26 @@ import { bodyAttributes } from './attributes'; if (typeof window !== 'undefined') { - const toDatasetKey = (dataKey: string) => dataKey.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + const toCamel = (key: string) => key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); - const act = { - // toggle:theme-test(dark,light) - toggle: (k: string, [on = 'on']: string[]) => { - document.body.dataset[k] = document.body.dataset[k] ? '' : on; - }, - // cycle:theme-test(dark,light) - cycle: (k: string, vals: string[]) => { - const cur = document.body.dataset[k] ?? vals[0]; - const next = vals[(vals.indexOf(cur) + 1) % vals.length]; - document.body.dataset[k] = next; - }, - // set:theme-test(dark) - set: (k: string, [v = '']: string[]) => { - document.body.dataset[k] = v; - }, - // attr:theme-test(data-theme) - attr: (k: string, [attr]: string[], el: HTMLElement) => { - document.body.dataset[k] = el.getAttribute(attr) ?? ''; - }, + const cycle = (target: HTMLElement, k: string, vals: string[]) => { + const cur = target.dataset[k] ?? vals[0]; // default = first value + const next = vals[(vals.indexOf(cur) + 1) % vals.length]; + target.dataset[k] = next; }; document.addEventListener('click', (e) => { const el = (e.target as HTMLElement).closest('[data-ui]'); if (!el) return; - const [, cmd, key, raw] = el.dataset.ui!.match(/^(\w+):([\w-]+)(?:\((.*?)\))?$/) || []; - if (!cmd || !(`data-${key}` in bodyAttributes)) return; + const [, key, rawVals = ''] = el.dataset.ui!.match(/^cycle:([\w-]+)(?:\((.*?)\))?$/) || []; - const dsKey = toDatasetKey(`data-${key}`); - console.log('dsKey: ', dsKey); - act[cmd as keyof typeof act]?.(dsKey, raw ? raw.split(',') : [], el); + if (!(`data-${key}` in bodyAttributes)) return; // unknown variant → bail + + const vals = rawVals.split(','); // '' → [''] OK for toggle + const dsKey = toCamel(`data-${key}`); + const target = (el.closest(`[data-${key}]`) as HTMLElement) ?? document.body; + + cycle(target, dsKey, vals); }); } diff --git a/examples/demo/src/app/zero-ui-ssr/Dashboard.tsx b/examples/demo/src/app/zero-ui-ssr/Dashboard.tsx index fd474cf..c36816c 100644 --- a/examples/demo/src/app/zero-ui-ssr/Dashboard.tsx +++ b/examples/demo/src/app/zero-ui-ssr/Dashboard.tsx @@ -1,13 +1,16 @@ +import { zeroSSR } from '../../../.zero-ui'; import { InnerDot } from './InnerDot'; import Link from 'next/link'; export const Dashboard: React.FC = () => { + const theme = 'theme-test'; + const themeValues = ['dark', 'light']; return (
+ + {/* expose current value for assertions */} +

{blur}

+
+ ); +} diff --git a/packages/core/__tests__/fixtures/next/app/page.tsx b/packages/core/__tests__/fixtures/next/app/page.tsx index d19bd6b..2ed0c5d 100644 --- a/packages/core/__tests__/fixtures/next/app/page.tsx +++ b/packages/core/__tests__/fixtures/next/app/page.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useScopedUI, useUI } from '@react-zero-ui/core'; +import { cssVar, useScopedUI, useUI } from '@react-zero-ui/core'; import UseEffectComponent from './UseEffectComponent'; import FAQ from './FAQ'; import { ChildComponent } from './ChildComponent'; import { ChildWithoutSetter } from './ChildWithoutSetter'; +import CssVarDemo from './CssVarDemo'; export default function Page() { const [scope, setScope] = useScopedUI<'off' | 'on'>('scope', 'off'); @@ -19,13 +20,15 @@ export default function Page() { const [, setToggleFunction] = useUI<'white' | 'black'>('toggle-function', 'white'); + const [global, setGlobal] = useUI<'0px' | '4px'>('blur-global', '0px', cssVar); + const toggleFunction = () => { setToggleFunction((prev) => (prev === 'white' ? 'black' : 'white')); }; return (

Global State


@@ -223,6 +226,28 @@ export default function Page() { question="Question 3" answer="Answer 3" /> + + {Array.from({ length: 2 }).map((_, index) => ( + + ))} +
+ +
+
); } diff --git a/packages/core/src/experimental/index.ts b/packages/core/src/experimental/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5bb2049..1edc334 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,26 +1,27 @@ 'use client'; import { useRef, type RefObject } from 'react'; -import { makeSetter } from './internal.js'; +import { cssVar, makeSetter } from './internal.js'; type UIAction = T | ((prev: T) => T); interface ScopedSetterFn { (action: UIAction): void; // ← SINGLE source of truth ref?: RefObject | ((node: HTMLElement | null) => void); + cssVar?: typeof cssVar; } type GlobalSetterFn = (action: UIAction) => void; -function useUI(key: string, initial: T): [T, GlobalSetterFn] { - return [initial, useRef(makeSetter(key, initial, () => document.body)).current as GlobalSetterFn]; +function useUI(key: string, initial: T, flag?: typeof cssVar): [T, GlobalSetterFn] { + return [initial, useRef(makeSetter(key, initial, () => document.body, flag)).current as GlobalSetterFn]; } -function useScopedUI(key: string, initialValue: T): [T, ScopedSetterFn] { +function useScopedUI(key: string, initialValue: T, flag?: typeof cssVar): [T, ScopedSetterFn] { // 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(null); - const setterFn = useRef(makeSetter(key, initialValue, () => scopeRef.current!)).current as ScopedSetterFn; + const setterFn = useRef(makeSetter(key, initialValue, () => scopeRef.current!, flag)).current as ScopedSetterFn; if (process.env.NODE_ENV !== 'production') { // -- DEV-ONLY MULTIPLE REF GUARD (removed in production by modern bundlers) -- @@ -53,5 +54,5 @@ function useScopedUI(key: string, initialValue: T): [ return [initialValue, setterFn]; } -export { useUI, useScopedUI }; +export { useUI, useScopedUI, cssVar }; export type { UIAction, ScopedSetterFn, GlobalSetterFn }; diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts index 2547f1e..93c36b1 100644 --- a/packages/core/src/internal.ts +++ b/packages/core/src/internal.ts @@ -1,8 +1,11 @@ -// internal.ts +// src/internal.ts +import { UIAction } from './index.js'; -export function makeSetter(key: string, initialValue: T, getTarget: () => HTMLElement) { - const camelKey = key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()); +export const cssVar: unique symbol = Symbol('cssVar'); +export function makeSetter(key: string, initialValue: T, getTarget: () => HTMLElement, flag?: typeof cssVar) { + const camelKey = key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()); + const isCss = flag === cssVar; if (process.env.NODE_ENV !== 'production') { if (key.includes(' ') || initialValue.includes(' ')) { throw new Error(`[Zero-UI] useUI(key, initialValue); key and initialValue must not contain spaces, got "${key}" and "${initialValue}"`); @@ -43,29 +46,19 @@ export function makeSetter(key: string, initialValue: T, getTa registry.set(key, initialValue); } } - return (valueOrFn: T | ((prev: T) => T)) => { + return (valueOrFn: UIAction) => { // SSR safety: bail out if running on server where window is undefined if (typeof window === 'undefined') return; const target = getTarget(); - if (process.env.NODE_ENV !== 'production') { - if (target === null) { - throw new Error( - `[Zero-UI] useScopedUI(key, initialValue); targetRef is null. \n` + - `This is likely due to a missing ref attachment. \n` + - `Solution: Attach a ref to the component.\n` + - `Example:
` - ); - } - } - // Write the new value to the data-* attribute - target.dataset[camelKey] = - // Check if caller passed an updater function (like React's setState(prev => prev === 'true' ? 'false' : 'true') pattern) + const prev = isCss ? ((target.style.getPropertyValue(`--${key}`) || initialValue) as T) : ((target.dataset[camelKey] || initialValue) as T); + + const next = typeof valueOrFn === 'function' - ? // Call the updater function with the parsed current value - fallback to initial value if not set - valueOrFn((target.dataset[camelKey] as T) ?? initialValue) - : // Direct value assignment (no updater function) - valueOrFn; + ? (valueOrFn as (p: T) => T)(prev) // ← CALL the updater + : valueOrFn; + + isCss ? target.style.setProperty(`--${key}`, next) : (target.dataset[camelKey] = next); }; } diff --git a/packages/eslint-plugin-react-zero-ui/src/rules/require-data-attr.ts b/packages/eslint-plugin-react-zero-ui/src/rules/require-data-attr.ts index 6b9464e..30bfe99 100644 --- a/packages/eslint-plugin-react-zero-ui/src/rules/require-data-attr.ts +++ b/packages/eslint-plugin-react-zero-ui/src/rules/require-data-attr.ts @@ -12,7 +12,7 @@ export default createRule({ docs: { description: 'Enforce data-* attribute on element using setter.ref' }, schema: [], messages: { - missingAttr: 'Element using "{{setter}}.ref" must include {{attr}} to avoid FOUC.', + missingAttr: 'Element using "{{setter}}.ref" should include {{attr}}=initialState to avoid FOUC.', missingRef: 'Setter "{{setter}}" from useScopedUI("{{key}}") was never attached via .ref.', }, },