Skip to content

Commit 88d4cfe

Browse files
authored
Feat:ssr safe onClick handler *experimental* (#19)
* added support to toggle css variables without re-renders * added new experimental SSR safe onClick handler * update deps * updated packages, linter, and ran tests * finish runtime compression
1 parent 30d88e0 commit 88d4cfe

38 files changed

+1562
-895
lines changed

.prettierrc.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@
1212
"bracketSameLine": true,
1313
"embeddedLanguageFormatting": "auto",
1414
"singleAttributePerLine": true
15-
}
15+
}

eslint.config.js

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// eslint.config.js - ESLint 9 flat-config, JS + CJS
22
import js from '@eslint/js';
3-
import nodePlugin from 'eslint-plugin-node';
3+
import nodePlugin from 'eslint-plugin-n';
44
import importPlugin from 'eslint-plugin-import';
5+
import tsParser from '@typescript-eslint/parser';
56

67
const nodeGlobals = {
78
require: 'readonly',
@@ -29,6 +30,8 @@ const browserGlobals = {
2930
clearTimeout: 'readonly',
3031
clearInterval: 'readonly',
3132
console: 'readonly',
33+
HTMLElement: 'readonly',
34+
Element: 'readonly',
3235
};
3336

3437
export default [
@@ -41,10 +44,10 @@ export default [
4144
/* 3 - CommonJS (*.cjs) */
4245
{
4346
files: ['**/*.cjs'],
44-
plugins: { node: nodePlugin, import: importPlugin },
47+
plugins: { n: nodePlugin, import: importPlugin },
4548
languageOptions: { ecmaVersion: 'latest', sourceType: 'script', globals: nodeGlobals },
4649
rules: {
47-
'node/no-unsupported-features/es-syntax': 'off',
50+
'n/no-unsupported-features/es-syntax': 'off',
4851
'import/no-unresolved': 'error', // Catch unresolved imports
4952
'import/named': 'error', // Catch missing named exports
5053
'import/default': 'error', // Catch missing default exports
@@ -55,16 +58,30 @@ export default [
5558
/* 4 - ES-module / browser (*.js) */
5659
{
5760
files: ['**/*.js'],
58-
plugins: { node: nodePlugin, import: importPlugin },
61+
plugins: { n: nodePlugin, import: importPlugin },
5962
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...nodeGlobals, ...browserGlobals } },
6063
rules: {
61-
'node/no-unsupported-features/es-syntax': 'off',
64+
'n/no-unsupported-features/es-syntax': 'off',
6265
'import/no-unresolved': 'error',
6366
'import/named': 'error',
6467
'import/default': 'error',
6568
'import/no-absolute-path': 'error',
6669
},
6770
},
6871

69-
/* 5 - JSX files */
72+
/* 5 - TypeScript files (*.ts, *.tsx) */
73+
{
74+
files: ['**/*.ts', '**/*.tsx'],
75+
plugins: { n: nodePlugin, import: importPlugin },
76+
languageOptions: { parser: tsParser, ecmaVersion: 'latest', sourceType: 'module', globals: { ...nodeGlobals, ...browserGlobals } },
77+
rules: {
78+
'n/no-unsupported-features/es-syntax': 'off',
79+
'import/no-unresolved': 'off', // TypeScript handles this
80+
'import/named': 'off', // TypeScript handles this
81+
'import/default': 'off', // TypeScript handles this
82+
'import/no-absolute-path': 'error',
83+
'no-unused-vars': 'off', // TypeScript handles this better
84+
'no-undef': 'off', // TypeScript handles this
85+
},
86+
},
7087
];

examples/demo/src/utils/env.ts

Lines changed: 1 addition & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1 @@
1-
type RenderEnv = 'client' | 'server';
2-
type HandoffState = 'pending' | 'complete';
3-
4-
class Env {
5-
current: RenderEnv = this.detect();
6-
handoffState: HandoffState = 'pending';
7-
currentId = 0;
8-
9-
set(env: RenderEnv): void {
10-
if (this.current === env) return;
11-
12-
this.handoffState = 'pending';
13-
this.currentId = 0;
14-
this.current = env;
15-
}
16-
17-
reset(): void {
18-
this.set(this.detect());
19-
}
20-
21-
nextId() {
22-
return ++this.currentId;
23-
}
24-
25-
get isServer(): boolean {
26-
return this.current === 'server';
27-
}
28-
29-
get isClient(): boolean {
30-
return this.current === 'client';
31-
}
32-
33-
private detect(): RenderEnv {
34-
if (typeof window === 'undefined' || typeof document === 'undefined') {
35-
return 'server';
36-
}
37-
38-
return 'client';
39-
}
40-
41-
handoff(): void {
42-
if (this.handoffState === 'pending') {
43-
this.handoffState = 'complete';
44-
}
45-
}
46-
47-
get isHandoffComplete(): boolean {
48-
return this.handoffState === 'complete';
49-
}
50-
}
51-
52-
// eslint-disable-next-line prefer-const
53-
export let env = new Env();
1+
export const env = { isClient: typeof window !== 'undefined', isServer: typeof window === 'undefined' };

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,15 @@
4343
"devDependencies": {
4444
"@eslint/js": "^9.32.0",
4545
"@types/node": "^24.1.0",
46+
"@typescript-eslint/eslint-plugin": "^8.38.0",
47+
"@typescript-eslint/parser": "^8.38.0",
4648
"esbuild": "^0.25.8",
4749
"eslint": "^9.32.0",
4850
"eslint-plugin-import": "^2.32.0",
49-
"eslint-plugin-node": "^11.1.0",
50-
"eslint-plugin-react-zero-ui": "workspace:*",
51+
"eslint-plugin-n": "^17.21.3",
5152
"prettier": "^3.6.2",
5253
"release-please": "^17.1.1",
5354
"tsx": "^4.20.3",
5455
"typescript": "^5.9.2"
5556
}
56-
}
57+
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ const scenarios = [
8484
initialText: 'Closed',
8585
toggledText: 'Open',
8686
},
87+
{
88+
name: 'SSR Theme Toggle',
89+
toggle: 'theme-ssr-toggle',
90+
container: 'theme-ssr-container',
91+
attr: 'data-theme-ssr',
92+
initialValue: 'light',
93+
toggledValue: 'dark',
94+
initialText: 'Light',
95+
toggledText: 'Dark',
96+
},
8797
];
8898

8999
test.describe.configure({ mode: 'serial' });

packages/core/__tests__/fixtures/next/.zero-ui/attributes.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@ export declare const bodyAttributes: {
1010
"data-scope": "off" | "on";
1111
"data-theme": "dark" | "light";
1212
"data-theme-2": "dark" | "light";
13+
"data-theme-ssr": "dark" | "light";
1314
"data-theme-three": "dark" | "light";
1415
"data-toggle-boolean": "false" | "true";
1516
"data-toggle-function": "black" | "blue" | "green" | "red" | "white";
1617
"data-use-effect-theme": "dark" | "light";
1718
};
19+
20+
export declare const variantKeyMap: {
21+
[key: string]: true;
22+
};

packages/core/__tests__/fixtures/next/.zero-ui/attributes.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,26 @@ export const bodyAttributes = {
55
"data-number": "1",
66
"data-theme": "light",
77
"data-theme-2": "light",
8+
"data-theme-ssr": "light",
89
"data-theme-three": "light",
910
"data-toggle-boolean": "true",
1011
"data-toggle-function": "white",
1112
"data-use-effect-theme": "light"
1213
};
14+
export const variantKeyMap = {
15+
"data-blur": true,
16+
"data-blur-global": true,
17+
"data-child": true,
18+
"data-dialog": true,
19+
"data-faq": true,
20+
"data-mobile": true,
21+
"data-number": true,
22+
"data-scope": true,
23+
"data-theme": true,
24+
"data-theme-2": true,
25+
"data-theme-ssr": true,
26+
"data-theme-three": true,
27+
"data-toggle-boolean": true,
28+
"data-toggle-function": true,
29+
"data-use-effect-theme": true
30+
};

packages/core/__tests__/fixtures/next/app/CssVarDemo.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ export default function CssVarDemo({ index = 0 }) {
1313
// 👇 pass `cssVar` flag to switch makeSetter into CSS-var mode
1414
const [blur, setBlur] = useScopedUI<'0px' | '4px'>('blur', '0px', cssVar);
1515
// global test
16-
17-
return (
16+
return (
1817
<div
1918
ref={setBlur.ref} // element that owns --blur
2019
data-testid={`demo-${index}`}

packages/core/__tests__/fixtures/next/app/page.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import FAQ from './FAQ';
55
import { ChildComponent } from './ChildComponent';
66
import { ChildWithoutSetter } from './ChildWithoutSetter';
77
import CssVarDemo from './CssVarDemo';
8+
9+
import { zeroSSR } from '@react-zero-ui/core/experimental';
10+
import ZeroUiRuntime from './zero-runtime';
11+
812

913
export default function Page() {
1014
const [scope, setScope] = useScopedUI<'off' | 'on'>('scope', 'off');
@@ -19,8 +23,8 @@ export default function Page() {
1923
const [, setChildOpen] = useUI<'open' | 'closed'>('child', 'closed');
2024

2125
const [, setToggleFunction] = useUI<'white' | 'black'>('toggle-function', 'white');
26+
const [, setGlobal] = useUI<'0px' | '4px'>('blur-global', '0px', cssVar);
2227

23-
const [global, setGlobal] = useUI<'0px' | '4px'>('blur-global', '0px', cssVar);
2428

2529
const toggleFunction = () => {
2630
setToggleFunction((prev) => (prev === 'white' ? 'black' : 'white'));
@@ -30,6 +34,7 @@ export default function Page() {
3034
<div
3135
className="p-8 theme-light:bg-white theme-dark:bg-white bg-black relative"
3236
data-testid="page-container">
37+
<ZeroUiRuntime />
3338
<h1 className="text-2xl font-bold py-5">Global State</h1>
3439
<hr />
3540
<div className=" space-y-4 border-2">
@@ -38,6 +43,32 @@ export default function Page() {
3843

3944
<hr className="my-8" />
4045

46+
<div
47+
data-theme-ssr="light"
48+
data-testid="theme-ssr-container"
49+
className="theme-ssr-dark:bg-gray-900 theme-ssr-light:bg-gray-100 theme-ssr-dark:text-white theme-ssr-light:text-black">
50+
<button
51+
data-testid="theme-ssr-toggle"
52+
className="border-2 border-red-500 theme-ssr-light:text-blue-500 theme-ssr-dark:text-red-500"
53+
{...zeroSSR.onClick('theme-ssr', ['light', 'dark'])}>
54+
Toggle SSR SAFE THEME
55+
</button>
56+
<div className="flex gap-2">
57+
Theme:
58+
<span
59+
data-testid="theme-ssr-dark"
60+
className="theme-ssr-dark:block hidden">
61+
Dark
62+
</span>
63+
<span
64+
data-testid="theme-ssr-light"
65+
className="theme-ssr-light:block hidden">
66+
Light
67+
</span>
68+
</div>
69+
</div>
70+
<hr className="my-8" />
71+
4172
<div
4273
className="theme-light:bg-gray-100 theme-dark:bg-gray-900 theme-dark:text-white"
4374
data-testid="theme-container">
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use client';
2+
3+
/* ① import the generated defaults */
4+
import { variantKeyMap } from '../.zero-ui/attributes';
5+
6+
/* ② activate the runtime shipped in the package */
7+
import { activateZeroUiRuntime } from '@react-zero-ui/core/experimental/runtime';
8+
9+
activateZeroUiRuntime(variantKeyMap);
10+
11+
export default function ZeroUiRuntime() {
12+
return null; // this component just runs the side effect
13+
}

0 commit comments

Comments
 (0)