From 11e3f1ccd72998c84ff52abecf7e4997f85a267f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 00:51:44 +0900 Subject: [PATCH 1/9] fix(eslint-plugin): keep composition arrays out of responsive rules Refs #684 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...ngepack_log_eslint_composition_arrays.json | 7 +++ .../src/rules/no-duplicate-value/index.ts | 4 +- .../src/rules/no-useless-responsive/index.ts | 5 +- .../rules/no-useless-tailing-nulls/index.ts | 4 +- .../__tests__/composition-arrays.test.ts | 52 +++++++++++++++++++ .../src/utils/responsive-position.ts | 36 +++++++++++++ 6 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 .changepacks/changepack_log_eslint_composition_arrays.json create mode 100644 packages/eslint-plugin/src/utils/__tests__/composition-arrays.test.ts create mode 100644 packages/eslint-plugin/src/utils/responsive-position.ts diff --git a/.changepacks/changepack_log_eslint_composition_arrays.json b/.changepacks/changepack_log_eslint_composition_arrays.json new file mode 100644 index 000000000..c17f4db47 --- /dev/null +++ b/.changepacks/changepack_log_eslint_composition_arrays.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "Do not treat composition arrays as responsive property values in the duplicate, trailing-null or single-value array rules. Preserve composition arrays and their nested responsive property diagnostics.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/packages/eslint-plugin/src/rules/no-duplicate-value/index.ts b/packages/eslint-plugin/src/rules/no-duplicate-value/index.ts index 1e7e70c99..730fe4fae 100644 --- a/packages/eslint-plugin/src/rules/no-duplicate-value/index.ts +++ b/packages/eslint-plugin/src/rules/no-duplicate-value/index.ts @@ -6,7 +6,7 @@ import { import type { RuleContext } from '@typescript-eslint/utils/ts-eslint' import { ImportStorage } from '../../utils/import-storage' -import { styleValueRoot } from '../../utils/style-position' +import { responsiveValueSite } from '../../utils/responsive-position' const createRule = ESLintUtils.RuleCreator( (name) => @@ -76,7 +76,7 @@ export const noDuplicateValue = createRule({ importStorage.addImportByDeclaration(node) }, ArrayExpression(node) { - if (styleValueRoot(node, importStorage)) + if (responsiveValueSite(node, importStorage)) checkDuplicateValue(node, context) }, } diff --git a/packages/eslint-plugin/src/rules/no-useless-responsive/index.ts b/packages/eslint-plugin/src/rules/no-useless-responsive/index.ts index 343fd0c05..d3d1a27cd 100644 --- a/packages/eslint-plugin/src/rules/no-useless-responsive/index.ts +++ b/packages/eslint-plugin/src/rules/no-useless-responsive/index.ts @@ -6,7 +6,7 @@ import { import type { RuleContext } from '@typescript-eslint/utils/ts-eslint' import { ImportStorage } from '../../utils/import-storage' -import { styleValueSite } from '../../utils/style-position' +import { responsiveValueSite } from '../../utils/responsive-position' const createRule = ESLintUtils.RuleCreator( (name) => @@ -32,6 +32,7 @@ function checkUselessResponsive>( case AST_NODE_TYPES.JSXOpeningElement: case AST_NODE_TYPES.CallExpression: case AST_NODE_TYPES.ObjectExpression: + case AST_NODE_TYPES.ArrayExpression: case AST_NODE_TYPES.JSXAttribute: break default: @@ -69,7 +70,7 @@ export const noUselessResponsive = createRule({ importStorage.addImportByDeclaration(node) }, ArrayExpression(node) { - const site = styleValueSite(node, importStorage) + const site = responsiveValueSite(node, importStorage) if (site) checkUselessResponsive( node, diff --git a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts index 94ecb4584..f98c81942 100644 --- a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts +++ b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/index.ts @@ -6,7 +6,7 @@ import { import type { RuleContext } from '@typescript-eslint/utils/ts-eslint' import { ImportStorage } from '../../utils/import-storage' -import { styleValueRoot } from '../../utils/style-position' +import { responsiveValueSite } from '../../utils/responsive-position' const createRule = ESLintUtils.RuleCreator( (name) => @@ -68,7 +68,7 @@ export const noUselessTailingNulls = createRule({ importStorage.addImportByDeclaration(node) }, ArrayExpression(node) { - if (styleValueRoot(node, importStorage)) + if (responsiveValueSite(node, importStorage)) checkUselessTailingNulls(node, context) }, } diff --git a/packages/eslint-plugin/src/utils/__tests__/composition-arrays.test.ts b/packages/eslint-plugin/src/utils/__tests__/composition-arrays.test.ts new file mode 100644 index 000000000..fa295db41 --- /dev/null +++ b/packages/eslint-plugin/src/utils/__tests__/composition-arrays.test.ts @@ -0,0 +1,52 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { describe } from 'bun:test' + +import { noDuplicateValue } from '../../rules/no-duplicate-value' +import { noUselessResponsive } from '../../rules/no-useless-responsive' +import { noUselessTailingNulls } from '../../rules/no-useless-tailing-nulls' + +describe('composition arrays are not responsive values', () => { + const tester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }) + const valid = [ + `import { css } from '@emotion/react'; css(base, 'extra', [{ p: 1 }], null)`, + `import { css } from '@emotion/react'; export const result = css(base, 'extra', [{ p: 1 }], null)`, + `import { style } from '@vanilla-extract/css'; export const result = style([[{ color: 'red' }], false && { margin: 0 }])`, + `import { css } from '@devup-ui/react'; css(['base', 'base', null])`, + `import { css } from '@devup-ui/react'; const parts = ['base', 'base', null]; css(parts)`, + `import { css } from '@devup-ui/react'; const parts = ['base']; const alias = parts; css(alias)`, + `import { Global } from '@emotion/react'; `, + `import styled from 'styled-components'; styled.div([{ color: 'red' }])`, + `import { css, Box } from '@devup-ui/react'; const parts = ['base']; css(parts); `, + `import { Box } from '@devup-ui/react'; `, + ].map((code) => ({ code, filename: 'composition.tsx' })) + tester.run('no-useless-responsive composition', noUselessResponsive, { + valid, + invalid: [ + { + code: `import { css } from '@devup-ui/react'; css([{ p: [1] }])`, + output: `import { css } from '@devup-ui/react'; css([{ p: 1 }])`, + filename: 'composition.tsx', + errors: [{ messageId: 'uselessResponsive' }], + }, + { + code: `import { Box } from '@devup-ui/react'; const value = [1]; `, + output: `import { Box } from '@devup-ui/react'; const value = 1; `, + filename: 'composition.tsx', + errors: [{ messageId: 'uselessResponsive' }], + }, + ], + }) + tester.run('no-duplicate-value composition', noDuplicateValue, { + valid, + invalid: [], + }) + tester.run('no-useless-tailing-nulls composition', noUselessTailingNulls, { + valid, + invalid: [], + }) +}) diff --git a/packages/eslint-plugin/src/utils/responsive-position.ts b/packages/eslint-plugin/src/utils/responsive-position.ts new file mode 100644 index 000000000..a891c96ba --- /dev/null +++ b/packages/eslint-plugin/src/utils/responsive-position.ts @@ -0,0 +1,36 @@ +import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils' + +import type { ImportStorage } from './import-storage' +import { componentName, type StyleSite, styleValueSite } from './style-position' + +/** Responsive values belong to a property, not to a composition argument. */ +export function responsiveValueSite( + node: TSESTree.Node, + imports: ImportStorage, +): StyleSite | null { + const site = styleValueSite(node, imports) + if (!site) return null + let child = node + let nestedArray = false + while (child !== site.start && child.parent) { + const parent = child.parent + if (parent.type === AST_NODE_TYPES.ArrayExpression) nestedArray = true + if (parent.type === AST_NODE_TYPES.Property) + return nestedArray ? null : site + if (parent.type === AST_NODE_TYPES.JSXAttribute) + return nestedArray || + componentName(parent.parent.name, imports) === 'Global' + ? null + : site + child = parent + } + if (site.start === site.root) return null + const declarator = site.start.parent + if (declarator?.type !== AST_NODE_TYPES.VariableDeclarator) return null + const [variable] = imports.declaredVariables(declarator) + return variable.references + .filter((reference) => !reference.init) + .every((reference) => responsiveValueSite(reference.identifier, imports)) + ? site + : null +} From 72781be4cdd9a73e395df8fb05ad946d7d1d0c75 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 01:03:15 +0900 Subject: [PATCH 2/9] fix(react): align StyleX and supported CSS property contracts Refs #684 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...hangepack_log_dead_css_property_types.json | 7 + ...hangepack_log_stylex_public_contracts.json | 7 + packages/react/src/compat/stylex.d.ts | 48 ++-- .../react/src/types/props/css-properties.ts | 24 +- packages/react/src/types/stylex-values.ts | 95 ++++++++ packages/react/src/types/stylex.ts | 112 +++++++++ .../react/src/utils/__tests__/stylex.test.ts | 11 +- packages/react/src/utils/stylex.ts | 130 +++++++---- .../react/type-tests/dead-css-properties.ts | 41 ++++ packages/react/type-tests/stylex.ts | 218 ++++++++++++++++++ 10 files changed, 625 insertions(+), 68 deletions(-) create mode 100644 .changepacks/changepack_log_dead_css_property_types.json create mode 100644 .changepacks/changepack_log_stylex_public_contracts.json create mode 100644 packages/react/src/types/stylex-values.ts create mode 100644 packages/react/src/types/stylex.ts create mode 100644 packages/react/type-tests/dead-css-properties.ts create mode 100644 packages/react/type-tests/stylex.ts diff --git a/.changepacks/changepack_log_dead_css_property_types.json b/.changepacks/changepack_log_dead_css_property_types.json new file mode 100644 index 000000000..b63675779 --- /dev/null +++ b/.changepacks/changepack_log_dead_css_property_types.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/react/package.json": "Patch" + }, + "note": "Reject the 18 unprefixed legacy flexbox and scroll-snap properties rejected by the compiler in component and StyleX property types. Keep vendor-prefixed compatibility properties, stroke-color and ime-mode.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_stylex_public_contracts.json b/.changepacks/changepack_log_stylex_public_contracts.json new file mode 100644 index 000000000..8953d6e55 --- /dev/null +++ b/.changepacks/changepack_log_stylex_public_contracts.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/react/package.json": "Patch" + }, + "note": "Type every compiled StyleX API with opaque styles, dynamic function arguments, conditional variables, partial themes and static null/string theme contract placeholders. Unsupported runtime shapes are rejected by TypeScript; no new build errors in this change.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/packages/react/src/compat/stylex.d.ts b/packages/react/src/compat/stylex.d.ts index 955c3c59f..f6e585b71 100644 --- a/packages/react/src/compat/stylex.d.ts +++ b/packages/react/src/compat/stylex.d.ts @@ -1,21 +1,39 @@ declare module '@stylexjs/stylex' { import { stylex } from '@devup-ui/react' - export { - attrs, - create, - createTheme, - createThemeContract, - defineConsts, - defineVars, - firstThatWorks, - include, - keyframes, - positionTry, - props, - types, - viewTransitionClass, - } from '@devup-ui/react/stylex' + export const attrs: typeof stylex.attrs + export const create: typeof stylex.create + export const createTheme: typeof stylex.createTheme + export const createThemeContract: typeof stylex.createThemeContract + export const defineConsts: typeof stylex.defineConsts + export const defineVars: typeof stylex.defineVars + export const firstThatWorks: typeof stylex.firstThatWorks + export const include: typeof stylex.include + export const keyframes: typeof stylex.keyframes + export const positionTry: typeof stylex.positionTry + export const props: typeof stylex.props + export const types: typeof stylex.types + export const viewTransitionClass: typeof stylex.viewTransitionClass + + export type StyleXAttrs = stylex.StyleXAttrs + export type StyleXCompiledStyle

= stylex.StyleXCompiledStyle

+ export type StyleXDeclarations = stylex.StyleXDeclarations + export type StyleXDynamicDeclarations = stylex.StyleXDynamicDeclarations + export type StyleXFlatDeclarations = stylex.StyleXFlatDeclarations + export type StyleXInclude

= stylex.StyleXInclude

+ export type StyleXInlineStyle = stylex.StyleXInlineStyle + export type StyleXProps = stylex.StyleXProps + export type StyleXRawStyles = stylex.StyleXRawStyles + export type StyleXStyleInput

= stylex.StyleXStyleInput

+ export type StyleXStyles

= stylex.StyleXStyles

+ export type StyleXThemeOverrides = + stylex.StyleXThemeOverrides + export type StyleXTypes = stylex.StyleXTypes + export type StyleXValue = stylex.StyleXValue + export type StyleXVariableValue = + stylex.StyleXVariableValue + export type StyleXVarGroup = + stylex.StyleXVarGroup export default stylex } diff --git a/packages/react/src/types/props/css-properties.ts b/packages/react/src/types/props/css-properties.ts index 3796749ba..9499ef0d4 100644 --- a/packages/react/src/types/props/css-properties.ts +++ b/packages/react/src/types/props/css-properties.ts @@ -4,6 +4,28 @@ import type { ResponsiveValue } from '../responsive-value' type Responsive = { [K in keyof T]?: ResponsiveValue } +type UnsupportedProperties = + | 'boxAlign' + | 'boxPack' + | 'boxFlex' + | 'boxFlexGroup' + | 'boxOrient' + | 'boxOrdinalGroup' + | 'boxDirection' + | 'boxLines' + | 'flexOrder' + | 'flexPositive' + | 'flexNegative' + | 'flexPreferredSize' + | 'scrollSnapCoordinate' + | 'scrollSnapDestination' + | 'scrollSnapPointsX' + | 'scrollSnapPointsY' + | 'scrollSnapTypeX' + | 'scrollSnapTypeY' + +export type SupportedCssProperties = Omit + // Keep an interface boundary so consumers can reuse cached type relationships. // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface DevupCssProperties extends Responsive {} +export interface DevupCssProperties extends Responsive {} diff --git a/packages/react/src/types/stylex-values.ts b/packages/react/src/types/stylex-values.ts new file mode 100644 index 000000000..e260a8da3 --- /dev/null +++ b/packages/react/src/types/stylex-values.ts @@ -0,0 +1,95 @@ +import type { SupportedCssProperties } from './props/css-properties' +export type StyleXScalar = string | number +export type StyleXAtRule = + `@media ${string}` | `@supports ${string}` | `@container ${string}` + +/** Variable conditions may nest at-rules, but cannot depend on an element. */ +export type StyleXVariableValue = + | T + | null + | { + readonly default?: StyleXVariableValue + readonly [condition: StyleXAtRule]: StyleXVariableValue + } + +export type StyleXValue = + | T + | null + | { + readonly default?: StyleXValue + readonly [condition: StyleXAtRule | `:${string}`]: StyleXValue + } + +export type StyleXCheckedValue = V extends object + ? { + readonly [K in keyof V]: K extends C ? StyleXCheckedValue : never + } + : V + +export type StyleXCheckedVariableValue = StyleXCheckedValue< + V, + 'default' | StyleXAtRule +> + +type CssProperties = SupportedCssProperties + +export type StyleXDeclarations = { + readonly [K in keyof CssProperties]?: StyleXValue +} & { + readonly [property: `--${string}`]: StyleXValue +} + +/** Dynamic namespace bodies and named declaration blocks are flat. */ +export type StyleXFlatDeclarations = { + readonly [K in keyof CssProperties]?: CssProperties[K] +} & { + readonly [property: `--${string}`]: StyleXScalar +} + +export type StyleXDynamicDeclarations = { + readonly [K in keyof CssProperties]?: CssProperties[K] | null +} & { + readonly [property: `--${string}`]: StyleXScalar | null +} + +export interface StyleXTypes { + angle>( + value: T & StyleXCheckedVariableValue, + ): T + color>( + value: T & StyleXCheckedVariableValue, + ): T + image>( + value: T & StyleXCheckedVariableValue, + ): T + integer>( + value: T & StyleXCheckedVariableValue, + ): T + length>( + value: T & StyleXCheckedVariableValue, + ): T + lengthPercentage>( + value: T & StyleXCheckedVariableValue, + ): T + number>( + value: T & StyleXCheckedVariableValue, + ): T + percentage>( + value: T & StyleXCheckedVariableValue, + ): T + resolution>( + value: T & StyleXCheckedVariableValue, + ): T + time>( + value: T & StyleXCheckedVariableValue, + ): T + transformFunction>( + value: T & StyleXCheckedVariableValue, + ): T + transformList>( + value: T & StyleXCheckedVariableValue, + ): T + url>( + value: T & StyleXCheckedVariableValue, + ): T +} diff --git a/packages/react/src/types/stylex.ts b/packages/react/src/types/stylex.ts new file mode 100644 index 000000000..fdb11bf02 --- /dev/null +++ b/packages/react/src/types/stylex.ts @@ -0,0 +1,112 @@ +import type { + StyleXAtRule, + StyleXCheckedValue, + StyleXCheckedVariableValue, + StyleXDeclarations, + StyleXDynamicDeclarations, + StyleXVariableValue, +} from './stylex-values' + +declare const compiledStyle: unique symbol +declare const variableGroup: unique symbol +declare const includedStyle: unique symbol + +/** Opaque build output, not a raw CSS object or a user-provided class string. */ +export type StyleXCompiledStyle

= { + readonly [compiledStyle]: P +} + +export type StyleXStyles

= + StyleXCompiledStyle

| false | null | undefined | readonly StyleXStyles

[] + +export type StyleXStyleInput

= + StyleXStyles

| string | readonly StyleXStyleInput

[] + +export type StyleXInclude

= { + readonly [includedStyle]: P +} + +export type StyleXRawStyles = StyleXDeclarations & { + readonly [pseudo: `:${string}`]: StyleXDeclarations +} & Partial + +export type StyleXNamespace = + StyleXRawStyles | null | ((...args: never[]) => StyleXDynamicDeclarations) + +type StyleLeaves = T extends object ? StyleLeaves : T +type DeclaredStyleShape = { + readonly [ + K in keyof S as K extends keyof StyleXDeclarations ? K : never + ]: StyleLeaves +} + +type StyleShape = + S extends StyleXInclude + ? Omit> & DeclaredStyleShape + : DeclaredStyleShape + +type CheckedDeclarations = { + readonly [K in keyof S]: K extends keyof StyleXDeclarations + ? StyleXCheckedValue + : never +} + +type CheckedRawStyles = { + readonly [K in keyof S]: K extends keyof StyleXDeclarations + ? StyleXCheckedValue + : K extends `:${string}` + ? CheckedDeclarations + : K extends typeof includedStyle + ? S[K] + : never +} + +export type StyleXCompiledNamespaces = { + readonly [K in keyof S]: S[K] extends (...args: infer A) => infer R + ? (...args: A) => StyleXCompiledStyle> + : StyleXCompiledStyle> +} + +export type StyleXCheckedNamespaces = { + readonly [K in keyof S]: S[K] extends null + ? null + : S[K] extends (...args: infer A) => infer R + ? ( + ...args: A + ) => R & + Record, never> + : S[K] & CheckedRawStyles +} + +export type StyleXVarGroup = { + readonly [variableGroup]: K +} & Readonly> + +export type StyleXThemeOverrides = Readonly< + Partial> +> + +export type StyleXCheckedVariables = { + readonly [K in keyof V]: StyleXCheckedVariableValue +} + +export type StyleXConstants = { + readonly [K in keyof C]: C[K] extends string | number | boolean + ? `${C[K]}` + : never +} + +export type StyleXInlineStyle = Readonly< + Record +> + +export type StyleXProps = { + readonly className: string + readonly style?: StyleXInlineStyle +} + +/** This extractor returns an inline object, unlike upstream attrs(). */ +export type StyleXAttrs = { + readonly class: string + readonly style?: StyleXInlineStyle +} diff --git a/packages/react/src/utils/__tests__/stylex.test.ts b/packages/react/src/utils/__tests__/stylex.test.ts index 6d5b4a953..4814939c5 100644 --- a/packages/react/src/utils/__tests__/stylex.test.ts +++ b/packages/react/src/utils/__tests__/stylex.test.ts @@ -67,9 +67,9 @@ describe('stylex', () => { }) it('include should throw at runtime', () => { - expect(() => include({ color: 'red' })).toThrowError( - 'Cannot run on the runtime', - ) + expect(() => + Reflect.apply(include, undefined, [{ color: 'red' }]), + ).toThrowError('Cannot run on the runtime') }) it('defineVars should throw at runtime', () => { @@ -80,7 +80,10 @@ describe('stylex', () => { it('createTheme should throw at runtime', () => { expect(() => - createTheme({ primary: '--x' }, { primary: 'blue' }), + Reflect.apply(createTheme, undefined, [ + { primary: '--x' }, + { primary: 'blue' }, + ]), ).toThrowError('Cannot run on the runtime') }) }) diff --git a/packages/react/src/utils/stylex.ts b/packages/react/src/utils/stylex.ts index e46905f6c..3e8fedbd8 100644 --- a/packages/react/src/utils/stylex.ts +++ b/packages/react/src/utils/stylex.ts @@ -1,84 +1,118 @@ -type StyleValue = string | number | null | undefined - -type StyleProperties = Record> - -interface StyleXTypes { - angle(value: T): T - color(value: T): T - image(value: T): T - integer(value: T): T - length(value: T): T - lengthPercentage(value: T): T - number(value: T): T - percentage(value: T): T - resolution(value: T): T - time(value: T): T - transformFunction(value: T): T - transformList(value: T): T - url(value: T): T -} - -export function create>( - _styles: S, -): { readonly [K in keyof S]: S[K] } { +import type { DevupCommonProps } from '../types/props' +import type { + StyleXAttrs, + StyleXCheckedNamespaces, + StyleXCheckedVariables, + StyleXCompiledNamespaces, + StyleXCompiledStyle, + StyleXConstants, + StyleXInclude, + StyleXNamespace, + StyleXProps, + StyleXStyleInput, + StyleXThemeOverrides, + StyleXVarGroup, +} from '../types/stylex' +import type { + StyleXFlatDeclarations, + StyleXScalar, + StyleXTypes, + StyleXVariableValue, +} from '../types/stylex-values' + +export type { + StyleXAttrs, + StyleXCompiledStyle, + StyleXInclude, + StyleXInlineStyle, + StyleXProps, + StyleXRawStyles, + StyleXStyleInput, + StyleXStyles, + StyleXThemeOverrides, + StyleXVarGroup, +} from '../types/stylex' +export type { + StyleXDeclarations, + StyleXDynamicDeclarations, + StyleXFlatDeclarations, + StyleXTypes, + StyleXValue, + StyleXVariableValue, +} from '../types/stylex-values' + +export function create< + const S extends Readonly>, +>(_styles: S & StyleXCheckedNamespaces): StyleXCompiledNamespaces { throw new Error('Cannot run on the runtime') } -export function props( - ..._styles: ReadonlyArray -): { className?: string; style?: Record } { +export function props(..._styles: readonly StyleXStyleInput[]): StyleXProps { throw new Error('Cannot run on the runtime') } -export function attrs( - ..._styles: ReadonlyArray -): { class?: string; style?: Record } { +export function attrs(..._styles: readonly StyleXStyleInput[]): StyleXAttrs { throw new Error('Cannot run on the runtime') } -export function keyframes(_frames: Record): string { +export function keyframes( + _frames: Readonly>, +): string { throw new Error('Cannot run on the runtime') } -export function firstThatWorks(..._values: T[]): T { +export function firstThatWorks( + ..._values: V +): V[number] { throw new Error('Cannot run on the runtime') } -export function include(_style: S): S { +export function include

(_style: StyleXCompiledStyle

): StyleXInclude

{ throw new Error('Cannot run on the runtime') } -export function defineVars>( - _vars: V, -): { readonly [K in keyof V]: string } { +export function defineVars< + const V extends Readonly>, +>( + _vars: V & StyleXCheckedVariables, +): StyleXVarGroup> { throw new Error('Cannot run on the runtime') } -export function createTheme>( - _vars: V, - - _overrides: { readonly [K in keyof V]: StyleValue }, -): Record { +export function createTheme< + K extends string, + const O extends Readonly>, +>( + _vars: StyleXVarGroup, + _overrides: O & + StyleXCheckedVariables & + StyleXThemeOverrides> & + Record>, never>, +): StyleXCompiledStyle { throw new Error('Cannot run on the runtime') } -export function createThemeContract>( - _vars: V, -): { readonly [K in keyof V]: string } { +export function createThemeContract< + const V extends Readonly>, +>(_vars: V): StyleXVarGroup> { throw new Error('Cannot run on the runtime') } -export function defineConsts>( - _consts: V, -): { readonly [K in keyof V]: V[K] } { +export function defineConsts< + const V extends Readonly>, +>(_consts: V): StyleXConstants { throw new Error('Cannot run on the runtime') } -export function positionTry(_fallback: StyleProperties): string { +export function positionTry( + _fallback: D & Record, never>, +): string { throw new Error('Cannot run on the runtime') } -export function viewTransitionClass(_styles: StyleProperties): string { +export function viewTransitionClass( + _styles: D & Record, never>, +): string { throw new Error('Cannot run on the runtime') } diff --git a/packages/react/type-tests/dead-css-properties.ts b/packages/react/type-tests/dead-css-properties.ts new file mode 100644 index 000000000..d6f0e817c --- /dev/null +++ b/packages/react/type-tests/dead-css-properties.ts @@ -0,0 +1,41 @@ +import type { DevupProps } from '../src' +import type { StyleXRawStyles } from '../src/utils/stylex' + +type Assert = T +type DeadCssProperty = + | 'boxAlign' + | 'boxPack' + | 'boxFlex' + | 'boxFlexGroup' + | 'boxOrient' + | 'boxOrdinalGroup' + | 'boxDirection' + | 'boxLines' + | 'flexOrder' + | 'flexPositive' + | 'flexNegative' + | 'flexPreferredSize' + | 'scrollSnapCoordinate' + | 'scrollSnapDestination' + | 'scrollSnapPointsX' + | 'scrollSnapPointsY' + | 'scrollSnapTypeX' + | 'scrollSnapTypeY' + +export type DeadPropertiesAreRejected = Assert< + Extract extends never ? true : false +> + +export type DeadStyleXPropertiesAreRejected = Assert< + Extract extends never ? true : false +> + +export const compatibilityProperties = { + WebkitBoxAlign: 'center', + WebkitBoxFlex: 2, + WebkitBoxOrient: 'vertical', + strokeColor: 'red', + imeMode: 'active', + scrollSnapType: 'x mandatory', + _hover: { WebkitBoxFlex: 3 }, +} satisfies DevupProps diff --git a/packages/react/type-tests/stylex.ts b/packages/react/type-tests/stylex.ts new file mode 100644 index 000000000..79b33be5e --- /dev/null +++ b/packages/react/type-tests/stylex.ts @@ -0,0 +1,218 @@ +import stylex, { + create as namedCreate, + defineVars as namedDefineVars, + props as namedProps, + type StyleXCompiledStyle, + type StyleXDynamicDeclarations, + type StyleXFlatDeclarations, + type StyleXRawStyles, + type StyleXStyles, + type StyleXVarGroup, +} from '@stylexjs/stylex' + +import * as direct from '../src/utils/stylex' + +type Assert = T +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false +type Accepts = [A] extends [B] ? true : false +type Rejects = Accepts extends false ? true : false +type InvalidVariableCondition = { + readonly default: 'red' + readonly ':hover': 'blue' +} +type InvalidVariableGroup = { readonly group: { readonly color: 'red' } } +type OfficialTransitionOptions = { readonly new: { readonly opacity: 0 } } +type ContractInput = Parameters[0] +type ConstantsInput = Parameters[0] +type ExtraThemeInput = Parameters< + typeof direct.createTheme<'text', { missing: 'red' }> +>[1] +type InvalidConditionInput = Parameters< + typeof direct.defineVars<{ text: InvalidVariableCondition }> +>[0] +type InvalidGroupInput = Parameters< + typeof direct.defineVars +>[0] +type OfficialTransitionInput = Parameters< + typeof direct.viewTransitionClass +>[0] + +const colors = namedDefineVars({ + text: { default: 'black', '@media print': 'gray' }, + accent: stylex.types.color({ + default: 'blue', + '@supports (color: oklch(0 0 0))': { + default: 'oklch(0.6 0.2 250)', + '@container sidebar (min-width: 400px)': 'red', + }, + }), + blank: null, +}) +const contract = stylex.createThemeContract({ + primary: null, + size: 'placeholder', +}) +const partialTheme = stylex.createTheme(colors, { text: 'navy' }) +const conditionTheme = stylex.createTheme(contract, { + primary: { default: 'red', '@media print': null }, +}) +const emptyTheme = stylex.createTheme(colors, {}) +const _consts = stylex.defineConsts({ + gap: 8, + flag: true, + color: 'red', + zero: -0, +}) +const animation = stylex.keyframes({ from: { opacity: 0 }, to: { opacity: 1 } }) +const fallback = stylex.firstThatWorks(4, 'auto') +const _emptyFallback = stylex.firstThatWorks() +const base = namedCreate({ + root: { + color: colors.text, + width: { default: fallback, ':hover': { '@media print': '50%' } }, + ':focus': { outlineColor: 'red' }, + animationName: animation, + '--local': 1, + }, + empty: null, + dynamic: (height: number, color: string | null) => ({ height, color }), +}) +const included = stylex.include(base.root) +const styles = stylex.create({ root: { ...included, color: 'blue' } }) +const _inherited = stylex.create({ root: { ...included, opacity: 1 } }) +declare const enabled: boolean +declare const external: StyleXStyles +declare const externalClass: string +const composition = [ + base.root, + [enabled && styles.root, null, undefined, false], +] as const +export const reactProps = namedProps( + partialTheme, + conditionTheme, + emptyTheme, + base.empty, + base.dynamic(20, null), + composition, + external, + 'external', + [externalClass, ['nested-external']], +) +export const domAttrs = stylex.attrs(composition, base.dynamic(10, 'red')) +export const tryName = stylex.positionTry({ top: 0, insetBlockEnd: 'auto' }) +export const transitionName = stylex.viewTransitionClass({ + animationDuration: '1s', +}) +export const scalarWrappers = [ + stylex.types.angle('1deg'), + stylex.types.color('red'), + stylex.types.image('url(image.png)'), + stylex.types.integer(1), + stylex.types.length(2), + stylex.types.lengthPercentage('50%'), + stylex.types.number(0.5), + stylex.types.percentage('20%'), + stylex.types.resolution('2dppx'), + stylex.types.time('1s'), + stylex.types.transformFunction('scale(2)'), + stylex.types.transformList('scale(2) translateX(1px)'), + stylex.types.url('url(image.png)'), +] as const +export const conditionalWrappers = [ + stylex.types.angle({ default: 1, '@media print': '0deg' }), + stylex.types.color({ default: 'red', '@media print': null }), + stylex.types.image({ default: 'none', '@media print': 'url(print.png)' }), + stylex.types.integer({ default: 1, '@media print': 2 }), + stylex.types.length({ default: 2, '@media print': '1px' }), + stylex.types.lengthPercentage({ default: 0, '@media print': '50%' }), + stylex.types.number({ default: 1, '@media print': 0 }), + stylex.types.percentage({ default: 0, '@media print': '20%' }), + stylex.types.resolution({ default: '1dppx', '@media print': '2dppx' }), + stylex.types.time({ default: '1s', '@media print': '0s' }), + stylex.types.transformFunction({ default: 'scale(2)', '@media print': null }), + stylex.types.transformList({ default: 'none', '@media print': null }), + stylex.types.url({ default: 'url(image.png)', '@media print': null }), +] as const + +export type StyleXContractChecks = [ + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert< + Equal< + Parameters, + [height: number, color: string | null] + > + >, + Assert, StyleXCompiledStyle>>, + Assert>>, + Assert>>, + Assert>>, + Assert>, + Assert>>, + Assert>>, + Assert[number]>>, + Assert>, + Assert>, + Assert>, + Assert< + Accepts + >, + Assert[0]>>, + Assert>, + Assert>>, + Assert>>, + Assert>>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert[0]>>, + Assert[0]>>, + Assert>, + Assert>, + Assert< + Equal< + keyof typeof direct.types, + | 'angle' + | 'color' + | 'image' + | 'integer' + | 'length' + | 'lengthPercentage' + | 'number' + | 'percentage' + | 'resolution' + | 'time' + | 'transformFunction' + | 'transformList' + | 'url' + > + >, + Assert, never>>, + Assert, never>>, + Assert>, + Assert>, + Assert>, + Assert>>, + Assert< + Accepts<{ '--n': 1; '--empty': null }, NonNullable> + >, +] From d7e7c631352b004093b5d6dc7e57f0250f38765e Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 01:14:06 +0900 Subject: [PATCH 3/9] fix(extractor): enforce the shared static styleOrder contract Refs #684 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_style_order_contract.json | 8 + .../extract_style_from_expression.rs | 9 +- libs/extractor/src/lib.rs | 4 + libs/extractor/src/style_order.rs | 118 ++++++++ libs/extractor/src/style_order_tests.rs | 268 ++++++++++++++++++ libs/extractor/src/style_order_validation.rs | 235 +++++++++++++++ libs/extractor/src/utils.rs | 88 ++---- libs/extractor/src/visit.rs | 77 ++++- libs/sheet/src/lib.rs | 12 +- .../__snapshots__/recommended.test.ts.snap | 2 +- .../src/rules/style-order-range/README.md | 53 ++-- .../__tests__/contract.test.ts | 65 +++++ .../src/rules/style-order-range/index.ts | 145 ++++++---- test-fixtures/style-order.json | 71 +++++ 14 files changed, 1001 insertions(+), 154 deletions(-) create mode 100644 .changepacks/changepack_log_style_order_contract.json create mode 100644 libs/extractor/src/style_order.rs create mode 100644 libs/extractor/src/style_order_tests.rs create mode 100644 libs/extractor/src/style_order_validation.rs create mode 100644 packages/eslint-plugin/src/rules/style-order-range/__tests__/contract.test.ts create mode 100644 test-fixtures/style-order.json diff --git a/.changepacks/changepack_log_style_order_contract.json b/.changepacks/changepack_log_style_order_contract.json new file mode 100644 index 000000000..b1ea234cd --- /dev/null +++ b/.changepacks/changepack_log_style_order_contract.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "Validate explicit static styleOrder as an integer from 1 to 254 or its canonical decimal digit string, consistently in extraction and lint. Invalid static values and conditional branches now produce located build errors instead of silently changing cascade order.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 0b4b43d40..5ab24b05d 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -10,9 +10,9 @@ use crate::{ ExtractResult, extract_style_from_member_expression::extract_style_from_member_expression, }, utils::{ - expression_to_code, get_number_by_literal_expression, get_str_by_property_key, - get_string_by_literal_expression, get_string_by_property_key, is_same_expression, - readable_code, unwrap_syntax_only, unwrap_syntax_only_mut, + expression_to_code, get_str_by_property_key, get_string_by_literal_expression, + get_string_by_property_key, is_same_expression, readable_code, unwrap_syntax_only, + unwrap_syntax_only_mut, }, }; use css::{ @@ -239,8 +239,7 @@ pub fn extract_style_from_expression<'a>( for disassembled in disassemble_property(&name) { let disassembled: &str = &disassembled; if name == "styleOrder" { - style_order = get_number_by_literal_expression(&prop.value) - .map(|v| v as u8); + style_order = crate::style_order::static_order(&prop.value); } else if name == "styleVars" { style_vars = Some(prop.value.clone_in(ast_builder.allocator())); diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index ac1dcbde6..05998e2d6 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -15,6 +15,10 @@ mod mutations; mod prop_modify_utils; mod prop_valid; mod source_map; +mod style_order; +#[cfg(test)] +mod style_order_tests; +mod style_order_validation; mod style_values; mod styled_reads; mod stylex; diff --git a/libs/extractor/src/style_order.rs b/libs/extractor/src/style_order.rs new file mode 100644 index 000000000..5a05d7527 --- /dev/null +++ b/libs/extractor/src/style_order.rs @@ -0,0 +1,118 @@ +//! Parsing reserved cascade orders without changing general numeric coercion. + +use boa_engine::JsString; +use oxc_allocator::{Allocator, CloneIn}; +use oxc_ast::ast::{Expression, JSXAttributeValue}; +use oxc_syntax::operator::{LogicalOperator, UnaryOperator}; + +use crate::utils::{ParsedStyleOrder, build_time_error, unwrap_syntax_only}; + +/// Canonical decimal digits naming one of the user-addressable layers. +pub(crate) fn string_order(value: &str) -> Option { + if !value + .as_bytes() + .first() + .copied() + .is_some_and(|byte| matches!(byte, b'1'..=b'9')) + || !value.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + value + .parse::() + .ok() + .filter(|order| (1..=254).contains(order)) +} + +/// A finite integer order or a canonical decimal string, never a truncated cast. +pub(crate) fn static_order(value: &Expression<'_>) -> Option { + let value = unwrap_syntax_only(value); + match value { + Expression::StringLiteral(literal) => string_order(literal.value.as_str()), + Expression::TemplateLiteral(template) if template.expressions.is_empty() => template + .quasis + .first() + .and_then(|quasi| quasi.value.cooked.as_ref()) + .and_then(|cooked| string_order(cooked.as_str())), + Expression::NumericLiteral(_) | Expression::UnaryExpression(_) => number_value(value) + .and_then(|number| number.to_string().parse::().ok()) + .filter(|order| (1..=254).contains(order)), + _ => None, + } +} + +/// Preserve literal numeric coercion for unary orders, never template interpolations. +fn number_value(value: &Expression<'_>) -> Option { + match unwrap_syntax_only(value) { + Expression::NumericLiteral(literal) => Some(literal.value), + Expression::StringLiteral(literal) => { + Some(JsString::from(literal.value.as_str()).to_number()) + } + Expression::BooleanLiteral(literal) => Some(f64::from(u8::from(literal.value))), + Expression::NullLiteral(_) => Some(0.0), + Expression::TemplateLiteral(template) if template.expressions.is_empty() => template + .quasis + .first() + .and_then(|quasi| quasi.value.cooked.as_ref()) + .map(|cooked| JsString::from(cooked.as_str()).to_number()), + Expression::UnaryExpression(unary) => match unary.operator { + UnaryOperator::UnaryNegation => number_value(&unary.argument).map(|number| -number), + UnaryOperator::UnaryPlus => number_value(&unary.argument), + UnaryOperator::LogicalNot + | UnaryOperator::BitwiseNot + | UnaryOperator::Typeof + | UnaryOperator::Void + | UnaryOperator::Delete => None, + }, + _ => None, + } +} + +/// Preserve the base's conditional representation and unresolved runtime fallback. +pub(crate) fn expression_order<'a>( + value: &Expression<'a>, + allocator: &'a Allocator, +) -> ParsedStyleOrder<'a> { + match unwrap_syntax_only(value) { + Expression::ConditionalExpression(conditional) => ParsedStyleOrder::Conditional { + condition: conditional.test.clone_in(allocator), + consequent: static_order(&conditional.consequent), + alternate: static_order(&conditional.alternate), + }, + Expression::LogicalExpression(logical) if logical.operator == LogicalOperator::And => { + ParsedStyleOrder::Conditional { + condition: logical.left.clone_in(allocator), + consequent: static_order(&logical.right), + alternate: None, + } + } + value => static_order(value).map_or(ParsedStyleOrder::None, ParsedStyleOrder::Static), + } +} + +/// Parse JSX strings as strings, not through the shared floating-point reader. +pub(crate) fn attribute_order<'a>( + value: &JSXAttributeValue<'a>, + allocator: &'a Allocator, +) -> ParsedStyleOrder<'a> { + match value { + JSXAttributeValue::StringLiteral(literal) => string_order(literal.value.as_str()) + .map_or(ParsedStyleOrder::None, ParsedStyleOrder::Static), + JSXAttributeValue::ExpressionContainer(container) => container + .expression + .as_expression() + .map_or(ParsedStyleOrder::None, |value| { + expression_order(value, allocator) + }), + JSXAttributeValue::Element(_) | JSXAttributeValue::Fragment(_) => ParsedStyleOrder::None, + } +} + +/// Explain an invalid explicit order through the existing build-time error family. +pub(crate) fn invalid_order(code: &str) -> String { + build_time_error( + "styleOrder", + code, + "an explicit styleOrder must be an integer from 1 to 254; a string must be canonical decimal digits without a sign, spaces or leading zeros: use a numeric literal such as 1 or a canonical decimal string such as \"1\"", + ) +} diff --git a/libs/extractor/src/style_order_tests.rs b/libs/extractor/src/style_order_tests.rs new file mode 100644 index 000000000..b9b32644f --- /dev/null +++ b/libs/extractor/src/style_order_tests.rs @@ -0,0 +1,268 @@ +use std::error::Error; + +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; +use serial_test::serial; + +use crate::style_order::static_order; +use crate::utils::ParsedStyleOrder; +use crate::{ExtractOption, extract}; + +const FIXTURES: &str = include_str!("../../../test-fixtures/style-order.json"); +const IMPORTS: &str = "import { Box, css, styled, keyframes } from '@devup-ui/react';\nimport { jsx as render } from 'react/jsx-runtime';\n"; + +fn expected_location(source: &str, expression: &str) -> Result> { + let leaf = expression + .rsplit_once(" : ") + .or_else(|| expression.rsplit_once(" && ")) + .map_or(expression, |(_, leaf)| leaf); + let root = source + .rfind(expression) + .ok_or("missing fixture expression")?; + let relative = expression.rfind(leaf).ok_or("missing fixture branch")?; + let before = source + .get(..root + relative) + .ok_or("invalid fixture offset")?; + let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = before + .rsplit('\n') + .next() + .ok_or("missing fixture line")? + .chars() + .count() + + 1; + Ok(format!("order.tsx:{line}:{column}:")) +} + +fn check_case(source: &str, expression: &str, valid: bool) -> Result<(), Box> { + reset_class_map(); + reset_file_map(); + let output = extract("order.tsx", source, ExtractOption::default()); + if valid { + assert!(output.is_ok(), "{expression}: {output:?}"); + } else { + let error = output + .err() + .ok_or_else(|| format!("invalid styleOrder unexpectedly compiled: {source}"))? + .to_string(); + let expected = expected_location(source, expression)?; + assert!( + error.lines().any(|line| line.starts_with(&expected)), + "{expected}\n{error}" + ); + assert!(error.contains("`styleOrder()` cannot use"), "{error}"); + assert!(error.contains("an integer from 1 to 254"), "{error}"); + assert!(error.contains("use a numeric literal"), "{error}"); + } + Ok(()) +} + +#[test] +#[serial] +fn consumed_orders_follow_shared_lint_fixtures() -> Result<(), Box> { + let fixtures: serde_json::Value = serde_json::from_str(FIXTURES)?; + for fixture in fixtures + .as_array() + .ok_or("styleOrder fixtures must be an array")? + { + let expression = fixture["expression"].as_str().ok_or("missing expression")?; + let valid = fixture["valid"].as_bool().ok_or("missing validity")?; + for body in [ + format!("const view = ;"), + format!("const view = render(Box, {{ bg: 'red', styleOrder: {expression} }});"), + format!("const rule = css({{ color: 'red', styleOrder: {expression} }});"), + format!("const Card = styled('div', {{ color: 'red', styleOrder: {expression} }});"), + format!( + "const fade = keyframes({{ from: {{ opacity: 0, styleOrder: {expression} }} }});" + ), + ] { + let source = format!("{IMPORTS}{body}"); + check_case(&source, expression, valid)?; + } + if expression.starts_with('\'') { + let source = format!("{IMPORTS}const view = ;"); + check_case(&source, expression, valid)?; + } + } + Ok(()) +} + +#[test] +fn scalar_orders_distinguish_number_values_and_cooked_strings() -> Result<(), Box> { + let allocator = Allocator::default(); + for (source, expected) in [ + ("1", Some(1)), + ("254", Some(254)), + ("'1'", Some(1)), + ("'254'", Some(254)), + ("100.0", Some(100)), + ("1e2", Some(100)), + ("+100", Some(100)), + ("-(-1)", Some(1)), + ("+\"1\"", Some(1)), + ("+\"01\"", Some(1)), + ("-\"-1\"", Some(1)), + ("+`01`", Some(1)), + ("+`\\u0031`", Some(1)), + ("+(\"1\" as const)", Some(1)), + ("+\"1.5\"", None), + ("+\"255\"", None), + ("+\"nope\"", None), + ("!1", None), + ("~1", None), + ("typeof 1", None), + ("'100.0'", None), + ("'1e2'", None), + ("'01'", None), + ("`\\u0031`", Some(1)), + ("`\\u0031\\u0030`", Some(10)), + ("`\\u0030\\u0031`", None), + ("`\\u0020\\u0031`", None), + ("0", None), + ("255", None), + ("1.5", None), + ("-1", None), + ("1e999", None), + ("`1${runtime}`", None), + ("+`1${runtime}`", None), + ("-(+`1${runtime}`)", None), + ("+runtime", None), + ] { + let expression = Parser::new(&allocator, source, SourceType::ts()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + assert_eq!(static_order(&expression), expected, "{source}"); + } + Ok(()) +} + +#[test] +fn conditional_orders_keep_the_implicit_false_branch_absent() -> Result<(), Box> { + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, "on && 2", SourceType::ts()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let order = crate::style_order::expression_order(&expression, &allocator); + let ParsedStyleOrder::Conditional { + consequent, + alternate, + .. + } = order + else { + panic!("logical styleOrder must retain its condition"); + }; + assert_eq!((consequent, alternate), (Some(2), None)); + Ok(()) +} + +#[test] +#[serial] +fn order_validation_keeps_pass_through_data_and_runtime_authority_unchanged() { + let source = format!("{IMPORTS} +const data = {{ styleOrder: '100px' }}; +const native =

; +const view = ; +const styledData = styled.div.attrs({{ styleOrder: '100px' }})({{ color: 'red' }}); +export const Runtime = (order) => ; +export const Shadowed = (undefined) => ;"); + reset_class_map(); + reset_file_map(); + assert!(extract("order.tsx", &source, ExtractOption::default()).is_ok()); +} + +#[test] +#[serial] +fn invalid_order_reports_the_written_branch_and_value_location() -> Result<(), Box> { + let expression = "on ? 1 : '100px'"; + let source = format!("{IMPORTS}const view = ;"); + reset_class_map(); + reset_file_map(); + let error = extract("order.tsx", &source, ExtractOption::default()) + .err() + .ok_or("invalid branch unexpectedly compiled")? + .to_string(); + let expected = expected_location(&source, expression)?; + assert!(error.contains("`'100px'`"), "{error}"); + assert!( + error.lines().any(|line| line.starts_with(&expected)), + "{error}" + ); + Ok(()) +} + +#[test] +fn style_order_validation_checks_only_consumed_roots() -> Result<(), Box> { + let allocator = Allocator::default(); + let bindings = crate::style_values::StyleValues::default(); + for (source, count) in [ + ("({styleOrder: 1n})", 1), + ("({styleOrder: /x/})", 1), + ("({styleOrder: []})", 1), + ("({styleOrder: {}})", 1), + ("({styleOrder: class {}})", 1), + ("({styleOrder: () => 1})", 1), + ("({styleOrder: function() {}})", 1), + ("({styleOrder: NaN})", 1), + ("({styleOrder: Infinity})", 1), + ("({styleOrder: void effect()})", 0), + ("({styleOrder: !runtime})", 0), + ("({styleOrder: ~runtime})", 0), + ("({styleOrder: typeof runtime})", 0), + ("({styleOrder: +`1${runtime}`})", 0), + ("({styleOrder: +\"nope\"})", 1), + ("({styleOrder: !1})", 1), + ("({styleOrder: ~1})", 1), + ("({styleOrder: typeof 1})", 1), + ("({styleOrder: !!1})", 1), + ("({styleOrder: delete 1})", 1), + ("({styleOrder: `1${runtime}`})", 0), + ("({...{styleOrder: 0}})", 1), + ("[{styleOrder: 0}, , ...runtime]", 1), + ("on ? {styleOrder: 0} : {styleOrder: 255}", 2), + ("on && {styleOrder: 0}", 1), + ("runtime", 0), + ("({color: {styleOrder: 0}})", 0), + ] { + let value = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let mut errors = Vec::new(); + crate::style_order_validation::validate_rules(&value, &bindings, &mut errors); + assert_eq!(errors.len(), count, "{source}: {errors:?}"); + } + Ok(()) +} + +#[test] +#[serial] +fn style_order_jsx_shapes_report_written_values() -> Result<(), Box> { + for (attribute, value) in [ + ("styleOrder", "styleOrder"), + ("styleOrder=", ""), + ("styleOrder=<>", "<>"), + ("styleOrder={}", ""), + ("styleOrder={<>}", "<>"), + ("styleOrder={on ? '01' : 2}", "'01'"), + ("styleOrder={!1}", "!1"), + ("styleOrder={~1}", "~1"), + ("styleOrder={typeof 1}", "typeof 1"), + ("styleOrder={on ? 2 : '\\u0030\\u0031'}", "'\\u0030\\u0031'"), + ] { + let source = format!("{IMPORTS}const view = ;"); + reset_class_map(); + reset_file_map(); + let error = extract("order.tsx", &source, ExtractOption::default()) + .err() + .ok_or("invalid JSX order compiled")? + .to_string(); + assert!(error.contains(&format!("`{value}`")), "{error}"); + let expected = expected_location(&source, value)?; + assert!( + error.lines().any(|line| line.starts_with(&expected)), + "{error}" + ); + } + Ok(()) +} diff --git a/libs/extractor/src/style_order_validation.rs b/libs/extractor/src/style_order_validation.rs new file mode 100644 index 000000000..20232300b --- /dev/null +++ b/libs/extractor/src/style_order_validation.rs @@ -0,0 +1,235 @@ +//! Validate only the root order fields consumed by style extraction. + +use oxc_allocator::{Allocator, CloneIn}; +use oxc_ast::ast::{Expression, JSXAttributeValue, ObjectPropertyKind}; +use oxc_span::GetSpan; +use oxc_syntax::operator::{LogicalOperator, UnaryOperator}; + +use crate::style_order::{invalid_order, static_order, string_order}; +use crate::style_values::StyleValues; +use crate::utils::{get_str_by_property_key, is_pure, readable_code, unwrap_syntax_only}; + +struct Validation<'s> { + bindings: &'s StyleValues, + errors: &'s mut Vec<(u32, String)>, +} + +impl Validation<'_> { + fn value(&mut self, value: &Expression<'_>) { + let value = unwrap_syntax_only(value); + if static_order(value).is_some() { + return; + } + match value { + Expression::ConditionalExpression(conditional) => { + self.value(&conditional.consequent); + self.value(&conditional.alternate); + } + Expression::LogicalExpression(logical) if logical.operator == LogicalOperator::And => { + self.value(&logical.right); + } + value if self.static_invalid(value) => { + let code = match value { + Expression::StringLiteral(literal) => literal + .raw + .as_ref() + .map_or_else(|| readable_code(value), ToString::to_string), + _ => readable_code(value), + }; + self.errors.push((value.span().start, invalid_order(&code))); + } + _ => {} + } + } + + fn static_invalid(&self, value: &Expression<'_>) -> bool { + match value { + Expression::NumericLiteral(_) + | Expression::StringLiteral(_) + | Expression::BooleanLiteral(_) + | Expression::NullLiteral(_) + | Expression::BigIntLiteral(_) + | Expression::RegExpLiteral(_) + | Expression::ArrayExpression(_) + | Expression::ObjectExpression(_) + | Expression::ClassExpression(_) + | Expression::JSXElement(_) + | Expression::JSXFragment(_) + | Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_) => true, + Expression::TemplateLiteral(template) => template.expressions.is_empty(), + Expression::Identifier(identifier) => { + matches!(identifier.name.as_str(), "undefined" | "NaN" | "Infinity") + && self.bindings.symbol(value).is_none() + } + Expression::UnaryExpression(unary) => match unary.operator { + UnaryOperator::Void => is_pure(&unary.argument), + UnaryOperator::UnaryNegation + | UnaryOperator::UnaryPlus + | UnaryOperator::LogicalNot + | UnaryOperator::BitwiseNot + | UnaryOperator::Typeof + | UnaryOperator::Delete => { + let argument = unwrap_syntax_only(&unary.argument); + static_order(argument).is_some() || self.static_invalid(argument) + } + }, + _ => false, + } + } + + fn rules(&mut self, value: &Expression<'_>) { + match unwrap_syntax_only(value) { + Expression::ObjectExpression(object) => { + for property in &object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) + if get_str_by_property_key(&property.key).as_deref() + == Some("styleOrder") => + { + self.value(&property.value); + } + ObjectPropertyKind::SpreadProperty(spread) => self.rules(&spread.argument), + ObjectPropertyKind::ObjectProperty(_) => {} + } + } + } + Expression::ArrayExpression(array) => { + for element in &array.elements { + if let Some(value) = element.as_expression() { + self.rules(value); + } + } + } + Expression::ConditionalExpression(conditional) => { + self.rules(&conditional.consequent); + self.rules(&conditional.alternate); + } + Expression::LogicalExpression(logical) if logical.operator == LogicalOperator::And => { + self.rules(&logical.right); + } + _ => {} + } + } +} + +/// Check declaration roots and composition parts, never arbitrary child data. +pub(crate) fn validate_rules( + value: &Expression<'_>, + bindings: &StyleValues, + errors: &mut Vec<(u32, String)>, +) { + Validation { bindings, errors }.rules(value); +} + +/// Frame declaration roots use the shared object-style extractor. +pub(crate) fn validate_frames( + value: &Expression<'_>, + bindings: &StyleValues, + errors: &mut Vec<(u32, String)>, +) { + if let Expression::ObjectExpression(object) = unwrap_syntax_only(value) { + let mut validation = Validation { bindings, errors }; + for property in &object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property { + validation.rules(&property.value); + } + } + } +} + +/// Reject invalid explicit JSX values at their own span before they are consumed. +pub(crate) fn validate_attribute( + value: &JSXAttributeValue<'_>, + bindings: &StyleValues, + errors: &mut Vec<(u32, String)>, +) { + match value { + JSXAttributeValue::ExpressionContainer(container) => { + if let Some(value) = container.expression.as_expression() { + Validation { bindings, errors }.value(value); + } + } + JSXAttributeValue::StringLiteral(literal) => { + if string_order(literal.value.as_str()).is_none() { + let code = literal + .raw + .as_ref() + .map_or_else(|| format!("{:?}", literal.value), ToString::to_string); + errors.push((literal.span.start, invalid_order(&code))); + } + } + JSXAttributeValue::Element(element) => { + let allocator = Allocator::default(); + let value = Expression::JSXElement(element.clone_in(&allocator)); + errors.push((element.span.start, invalid_order(&readable_code(&value)))); + } + JSXAttributeValue::Fragment(fragment) => { + let allocator = Allocator::default(); + let value = Expression::JSXFragment(fragment.clone_in(&allocator)); + errors.push((fragment.span.start, invalid_order(&readable_code(&value)))); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oxc_parser::Parser; + use oxc_span::SourceType; + + #[test] + fn style_order_generated_literals_use_readable_fallbacks() { + let allocator = Allocator::default(); + let builder = oxc_ast::builder::AstBuilder::new(&allocator); + let bindings = StyleValues::default(); + let mut errors = Vec::new(); + let value = Expression::new_string_literal(oxc_span::SPAN, "01", None, &builder); + Validation { + bindings: &bindings, + errors: &mut errors, + } + .value(&value); + let attribute = JSXAttributeValue::new_string_literal(oxc_span::SPAN, "01", None, &builder); + validate_attribute(&attribute, &bindings, &mut errors); + assert_eq!(errors.len(), 2); + assert!(errors.iter().all(|(_, error)| error.contains("`\"01\"`"))); + } + + #[test] + fn style_order_empty_jsx_and_non_frame_values_remain_absent() { + let allocator = Allocator::default(); + let bindings = StyleValues::default(); + let parsed = Parser::new( + &allocator, + "", + SourceType::tsx(), + ) + .parse(); + assert_eq!(parsed.diagnostics.len(), 1); + let oxc_ast::ast::Statement::ExpressionStatement(statement) = &parsed.program.body[0] + else { + panic!("expected expression statement") + }; + let value = &statement.expression; + let Expression::JSXElement(element) = value else { + panic!("expected JSX") + }; + let oxc_ast::ast::JSXAttributeItem::Attribute(attribute) = + &element.opening_element.attributes[0] + else { + panic!("expected attribute") + }; + let Some(attribute) = attribute.value.as_ref() else { + panic!("expected attribute value") + }; + let mut errors = Vec::new(); + validate_attribute(attribute, &bindings, &mut errors); + validate_frames(value, &bindings, &mut errors); + assert_eq!(errors, vec![]); + assert!(matches!( + crate::style_order::attribute_order(attribute, &allocator), + crate::utils::ParsedStyleOrder::None + )); + } +} diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index e5fb825c0..a2c22af8f 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -215,17 +215,7 @@ pub(super) fn jsx_expression_to_style_order<'a>( expr: &JSXAttributeValue<'a>, allocator: &'a Allocator, ) -> ParsedStyleOrder<'a> { - match expr { - JSXAttributeValue::ExpressionContainer(ec) => ec - .expression - .as_expression() - .map_or(ParsedStyleOrder::None, |e| { - expression_to_style_order(e, allocator) - }), - _ => jsx_expression_to_number(expr).map_or(ParsedStyleOrder::None, |n| { - ParsedStyleOrder::Static(n as u8) - }), - } + crate::style_order::attribute_order(expr, allocator) } /// Parse styleOrder from an Expression (for call expression / object path), supporting conditionals @@ -233,46 +223,7 @@ pub(super) fn expression_to_style_order<'a>( expr: &Expression<'a>, allocator: &'a Allocator, ) -> ParsedStyleOrder<'a> { - // Inspect `expr` ONCE. A numeric-literal probe (`get_number_by_literal_expression`) - // never matches a conditional/logical node, so folding it into the default arm is - // behavior-identical to the former "static probe first, then re-match" flow while - // avoiding the redundant second inspection of `expr`. - match expr { - // Conditional: `cond ? a : b` → Conditional with both branches probed. - Expression::ConditionalExpression(cond) => { - let consequent = get_number_by_literal_expression(&cond.consequent).map(|n| n as u8); - let alternate = get_number_by_literal_expression(&cond.alternate).map(|n| n as u8); - ParsedStyleOrder::Conditional { - condition: cond.test.clone_in(allocator), - consequent, - alternate, - } - } - // Logical &&: `a === 1 && 5` → truthy → right side (number), falsy → None. - Expression::LogicalExpression(logical) if logical.operator == LogicalOperator::And => { - let consequent = get_number_by_literal_expression(&logical.right).map(|n| n as u8); - ParsedStyleOrder::Conditional { - condition: logical.left.clone_in(allocator), - consequent, - alternate: None, - } - } - // Otherwise fall back to static numeric-literal resolution. - _ => get_number_by_literal_expression(expr).map_or(ParsedStyleOrder::None, |n| { - ParsedStyleOrder::Static(n as u8) - }), - } -} - -pub(super) fn jsx_expression_to_number(expr: &JSXAttributeValue) -> Option { - match expr { - JSXAttributeValue::StringLiteral(sl) => sl.value.parse::().ok(), - JSXAttributeValue::ExpressionContainer(ec) => ec - .expression - .as_expression() - .and_then(get_number_by_literal_expression), - _ => None, - } + crate::style_order::expression_order(expr, allocator) } pub(super) fn get_number_by_literal_expression(expr: &Expression) -> Option { @@ -1659,7 +1610,7 @@ mod tests { let allocator = Allocator::default(); let builder = oxc_ast::builder::AstBuilder::new(&allocator); assert_eq!( - jsx_expression_to_number( + jsx_expression_to_style_order( JSXAttribute::new( SPAN, JSXAttributeName::new_identifier(SPAN, "styleOrder", &builder), @@ -1670,13 +1621,16 @@ mod tests { ) .value .as_ref() - .unwrap() - ), + .unwrap(), + &allocator, + ) + .as_static() + .map(f64::from), Some(1.0) ); assert_eq!( - jsx_expression_to_number( + jsx_expression_to_style_order( JSXAttribute::new( SPAN, JSXAttributeName::new_identifier(SPAN, "styleOrder", &builder), @@ -1705,18 +1659,26 @@ mod tests { ) .value .as_ref() - .unwrap() - ), + .unwrap(), + &allocator, + ) + .as_static() + .map(f64::from), None ); assert_eq!( - jsx_expression_to_number(&JSXAttributeValue::new_expression_container( - SPAN, - Expression::new_numeric_literal(SPAN, 2.0, None, NumberBase::Decimal, &builder) - .into(), - &builder, - )), + jsx_expression_to_style_order( + &JSXAttributeValue::new_expression_container( + SPAN, + Expression::new_numeric_literal(SPAN, 2.0, None, NumberBase::Decimal, &builder) + .into(), + &builder, + ), + &allocator, + ) + .as_static() + .map(f64::from), Some(2.0) ); } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index f78d276d3..2888aa311 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -2426,6 +2426,19 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if is_styled { self.style_values.read_in(&self.ast, it); if let Expression::CallExpression(call) = &*it { + let first_rule = usize::from(matches!( + unwrap_syntax_only(&call.callee), + Expression::Identifier(_) + )); + for argument in call.arguments.iter().skip(first_rule) { + if let Some(value) = argument.as_expression() { + crate::style_order_validation::validate_rules( + value, + &self.style_values, + &mut self.errors, + ); + } + } self.unknown_arguments("styled", &call.arguments); self.changed_arguments("styled", &call.arguments); } @@ -2803,6 +2816,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && let [arg] = call.arguments.as_mut_slice() && let Some(arg @ Expression::ObjectExpression(_)) = arg.as_expression_mut() { + crate::style_order_validation::validate_frames( + arg, + &self.style_values, + &mut self.errors, + ); let KeyframesExtractResult { keyframes, runtime_value, @@ -2891,6 +2909,23 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { )); } + if let Expression::CallExpression(call) = &*it + && self + .util_type(&call.callee) + .is_some_and(|util| matches!(util.as_ref(), UtilType::Css)) + { + for argument in &call.arguments { + let value = match argument { + Argument::SpreadElement(spread) => &spread.argument, + argument => argument.to_expression(), + }; + crate::style_order_validation::validate_rules( + value, + &self.style_values, + &mut self.errors, + ); + } + } if let Expression::CallExpression(call) = it && self .util_type(&call.callee) @@ -2993,17 +3028,21 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } } } else if matches!(r, UtilType::Keyframes) { + let frames = if let Argument::SpreadElement(spread) = &mut call.arguments[0] + { + &mut spread.argument + } else { + call.arguments[0].to_expression_mut() + }; + crate::style_order_validation::validate_frames( + frames, + &self.style_values, + &mut self.errors, + ); let KeyframesExtractResult { keyframes, runtime_value, - } = extract_keyframes_from_expression( - &self.ast, - if let Argument::SpreadElement(spread) = &mut call.arguments[0] { - &mut spread.argument - } else { - call.arguments[0].to_expression_mut() - }, - ); + } = extract_keyframes_from_expression(&self.ast, frames); if let Some(value) = runtime_value { self.errors .push((offset, runtime_value_error("keyframes", &value))); @@ -3277,6 +3316,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { { // Pre-scan: detect conditional styleOrder before extract_style_from_expression // consumes the property (which only handles static values) + crate::style_order_validation::validate_rules( + it.arguments[1].to_expression(), + &self.style_values, + &mut self.errors, + ); let parsed_style_order = if let Expression::ObjectExpression(obj) = it.arguments[1].to_expression() { obj.properties.iter().find_map(|prop| { @@ -3342,7 +3386,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { // Use pre-scanned ParsedStyleOrder, falling back to extract_style_from_expression's // static result for backward compat. - // Note: pre-scan and extract_style_from_expression both use get_number_by_literal_expression + // Note: pre-scan and extract_style_from_expression both use style_order::static_order // on the same value, so style_order is always None when parsed_style_order is None. let parsed_style_order = match parsed_style_order { ParsedStyleOrder::None => { @@ -3831,8 +3875,18 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if !duplicate_set.contains(&disassembled) { if property_name == "styleOrder" { if let Some(value) = attr.value.as_ref() { + crate::style_order_validation::validate_attribute( + value, + &self.style_values, + &mut self.errors, + ); parsed_style_order = jsx_expression_to_style_order(value, self.ast.allocator()); + } else { + self.errors.push(( + attr.span.start, + crate::style_order::invalid_order("styleOrder"), + )); } } else if property_name == "props" { if let Some(value) = attr.value.as_ref() @@ -3893,6 +3947,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { duplicate_set.extend(given.into_iter().map(Cow::Owned)); } // Extract styles from spread attributes (e.g., {...{"@media": {...}}}) + crate::style_order_validation::validate_rules( + &spread.argument, + &self.style_values, + &mut self.errors, + ); let ExtractResult { styles, .. } = extract_style_from_expression( &self.ast, None, diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 9a989df8f..64410a232 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -3686,9 +3686,9 @@ mod tests { reset_class_map(); reset_file_map(); let mut sheet = StyleSheet::default(); - let output = extract( + let mut output = extract( "test.tsx", - "import {Box} from '@devup-ui/core'\n", + "import {Box} from '@devup-ui/core'\n", ExtractOption { package: "@devup-ui/core".to_string(), css_dir: "@devup-ui/core".to_string(), @@ -3698,6 +3698,14 @@ mod tests { }, ) .unwrap(); + output.styles = output + .styles + .into_iter() + .map(|mut style| { + style.set_style_order(0); + style + }) + .collect(); assert_eq!( sheet.update_styles(&output.styles, "test.tsx", true), (true, true) diff --git a/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap b/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap index e4e932193..e2940d14f 100644 --- a/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap +++ b/packages/eslint-plugin/src/configs/__tests__/__snapshots__/recommended.test.ts.snap @@ -133,7 +133,7 @@ exports[`recommended export recommended config 1`] = ` "url": "https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/style-order-range", }, "messages": { - "styleOrderRange": "styleOrder prop must be a number greater than 0 and less than 255.", + "styleOrderRange": "styleOrder must be an integer from 1 to 254 or its decimal digit string, with no signs, spaces or leading zeros.", "wrongType": "styleOrder prop must be a number or a string representing a number.", }, "schema": [], diff --git a/packages/eslint-plugin/src/rules/style-order-range/README.md b/packages/eslint-plugin/src/rules/style-order-range/README.md index 6058580dc..69d0e1630 100644 --- a/packages/eslint-plugin/src/rules/style-order-range/README.md +++ b/packages/eslint-plugin/src/rules/style-order-range/README.md @@ -4,47 +4,58 @@ Ensures `styleOrder` prop is within valid range (0 < value < 255). ## Rule Details -This rule enforces that the `styleOrder` prop must be a number greater than 0 and less than 255. +An explicit `styleOrder` must be an integer from 1 to 254, or the canonical +decimal string for that integer. Strings must not contain signs, whitespace, +leading zeros, decimal points, exponents or suffixes. Numeric expressions such +as `100.0` and `1e2` are integers and are accepted. The extractor reserves order +0 for its internal global layer and 255 for unlayered styles. ### Examples of **incorrect** code for this rule: ```jsx +import { Box } from '@devup-ui/react' + // Zero and negative values -
-
-
+ + + // Values greater than or equal to 255 -
-
-
+ + + // Non-numeric values -
-
+ + + + + + ``` ### Examples of **correct** code for this rule: ```jsx +import { Box } from '@devup-ui/react' + // Valid range values (0 < value < 255) -
-
-
-
-
-
+ + + + + + ``` ## Where it applies -The rule checks `styleOrder` where the build reads it: a prop of a Devup UI component and a top-level key of a style object passed to `css`, `globalCss` or `keyframes`. A `styleOrder` in nested data (`data-*` props, `props`, an element passed as a prop, nested style objects) is not checked. +The rule checks Devup UI component props and top-level style-object keys of the +recognized utilities and styled factories, including their supported import +aliases. It does not inspect `.attrs()` data, pass-through props, native elements +or vanilla-extract stylesheets (`.css.ts`, `.css.js`). Conditional branches use +the same value contract. A native `
` is not checked. ## When Not To Use It If you don't use `styleOrder` props or want to allow any value range, you can disable this rule. - - -## Where it applies - -`styleOrder` is read as a prop of a Devup UI component and as a top-level key of a style object passed to `css`, `globalCss`, `keyframes` or `createGlobalStyle`, through the same import aliases the build compiles by default (`@emotion/react`, `@emotion/styled`, `styled-components`, `@vanilla-extract/css`), and in the rules of `styled` (`styled.div({ styleOrder: 1 })`, `styled('div', { styleOrder: 1 })`). It is not read in `.attrs()` or in vanilla-extract stylesheets (`.css.ts`, `.css.js`). diff --git a/packages/eslint-plugin/src/rules/style-order-range/__tests__/contract.test.ts b/packages/eslint-plugin/src/rules/style-order-range/__tests__/contract.test.ts new file mode 100644 index 000000000..064516362 --- /dev/null +++ b/packages/eslint-plugin/src/rules/style-order-range/__tests__/contract.test.ts @@ -0,0 +1,65 @@ +import { readFileSync } from 'node:fs' + +import { RuleTester } from '@typescript-eslint/rule-tester' +import { describe } from 'bun:test' + +import { styleOrderRange } from '../index' + +const cases: { expression: string; valid: boolean }[] = JSON.parse( + readFileSync( + new URL( + '../../../../../../test-fixtures/style-order.json', + import.meta.url, + ), + 'utf8', + ), +) + +describe('styleOrder matches the extractor contract', () => { + const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }) + const fixtures = cases.flatMap(({ expression, valid }) => + [ + `import { Box } from '@devup-ui/react'; `, + `import { css } from '@devup-ui/react'; css({ p: 1, styleOrder: ${expression} })`, + ].map((code) => ({ code, valid, filename: 'contract.tsx' })), + ) + ruleTester.run('style-order-range contract', styleOrderRange, { + valid: fixtures + .filter(({ valid }) => valid) + .map(({ code, filename }) => ({ code, filename })) + .concat( + { + code: `import { css } from '@devup-ui/react'; css({ styleOrder: 1 })`, + filename: 'contract.ts', + }, + { + code: `import { css } from '@devup-ui/react'; css({ styleOrder: +(1) })`, + filename: 'contract.ts', + }, + ), + invalid: fixtures + .filter(({ valid }) => !valid) + .map(({ code, filename }) => ({ + code, + filename, + errors: [{ messageId: 'styleOrderRange' }], + })) + .concat([ + { + code: `import { Box } from '@devup-ui/react'; `, + filename: 'contract.tsx', + errors: [{ messageId: 'styleOrderRange' }], + }, + { + code: `import { css } from '@devup-ui/react'; css({ 'styleOrder': '100px' })`, + filename: 'contract.tsx', + errors: [{ messageId: 'styleOrderRange' }], + }, + ]), + }) +}) diff --git a/packages/eslint-plugin/src/rules/style-order-range/index.ts b/packages/eslint-plugin/src/rules/style-order-range/index.ts index b5154caa9..95f3efd5f 100644 --- a/packages/eslint-plugin/src/rules/style-order-range/index.ts +++ b/packages/eslint-plugin/src/rules/style-order-range/index.ts @@ -13,60 +13,93 @@ const createRule = ESLintUtils.RuleCreator( `https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/${name}`, ) -function checkStyleOrderRange>( - expression: TSESTree.Expression, - context: T, -) { - let value: number | null = null +function isStyleOrder(value: number | string): boolean { + const number = Number(value) + return ( + Number.isInteger(number) && + number >= 1 && + number <= 254 && + (typeof value === 'number' || value === String(number)) + ) +} - if (expression.type === AST_NODE_TYPES.Literal) { - if (typeof expression.value === 'number') { - value = expression.value - } else if (typeof expression.value === 'string') { - const parsed = parseInt(expression.value, 10) - if (!Number.isNaN(parsed)) { - value = parsed - } +function numericValue(expression: TSESTree.Expression): number | null { + switch (expression.type) { + case AST_NODE_TYPES.Literal: + return typeof expression.value === 'number' + ? expression.value + : typeof expression.value === 'string' || + typeof expression.value === 'boolean' || + expression.value === null + ? Number(expression.value) + : null + case AST_NODE_TYPES.TemplateLiteral: + return expression.expressions.length === 0 && + expression.quasis[0].value.cooked !== null + ? Number(expression.quasis[0].value.cooked) + : null + case AST_NODE_TYPES.UnaryExpression: { + if (expression.operator !== '+' && expression.operator !== '-') + return null + const value = numericValue(expression.argument) + return value === null + ? null + : expression.operator === '-' + ? -value + : value } - } else if (expression.type === AST_NODE_TYPES.UnaryExpression) { - if ( - expression.argument.type === AST_NODE_TYPES.Literal && - typeof expression.argument.value === 'number' && - (expression.operator === '-' || expression.operator === '+') - ) { - value = - expression.operator === '-' - ? -expression.argument.value - : expression.argument.value - } else { - context.report({ - node: expression, - messageId: 'styleOrderRange', - }) - return - } - } else if (expression.type === AST_NODE_TYPES.TemplateLiteral) { - if (expression.expressions.length > 0) { - // error report - context.report({ - node: expression, - messageId: 'styleOrderRange', - }) - return - } else { - value = parseInt(expression.quasis[0].value.raw, 10) - if (Number.isNaN(value)) { - // error report - context.report({ - node: expression, - messageId: 'styleOrderRange', - }) - return - } + case AST_NODE_TYPES.TSAsExpression: + case AST_NODE_TYPES.TSSatisfiesExpression: + case AST_NODE_TYPES.TSNonNullExpression: + case AST_NODE_TYPES.TSTypeAssertion: + return numericValue(expression.expression) + default: + return null + } +} + +function isStyleOrderExpression(expression: TSESTree.Expression): boolean { + switch (expression.type) { + case AST_NODE_TYPES.Literal: + return ( + (typeof expression.value === 'number' || + typeof expression.value === 'string') && + isStyleOrder(expression.value) + ) + case AST_NODE_TYPES.UnaryExpression: { + const value = numericValue(expression) + return value !== null && isStyleOrder(value) } + case AST_NODE_TYPES.TemplateLiteral: + return ( + expression.expressions.length === 0 && + expression.quasis[0].value.cooked !== null && + isStyleOrder(expression.quasis[0].value.cooked) + ) + case AST_NODE_TYPES.ConditionalExpression: + return ( + isStyleOrderExpression(expression.consequent) && + isStyleOrderExpression(expression.alternate) + ) + case AST_NODE_TYPES.LogicalExpression: + return ( + expression.operator === '&&' && isStyleOrderExpression(expression.right) + ) + case AST_NODE_TYPES.TSAsExpression: + case AST_NODE_TYPES.TSSatisfiesExpression: + case AST_NODE_TYPES.TSNonNullExpression: + case AST_NODE_TYPES.TSTypeAssertion: + return isStyleOrderExpression(expression.expression) + default: + return false } +} - if (value === null || value < 1 || value > 254) { +function checkStyleOrderRange>( + expression: TSESTree.Expression, + context: T, +) { + if (!isStyleOrderExpression(expression)) { context.report({ node: expression, messageId: 'styleOrderRange', @@ -81,7 +114,7 @@ export const styleOrderRange = createRule({ schema: [], messages: { styleOrderRange: - 'styleOrder prop must be a number greater than 0 and less than 255.', + 'styleOrder must be an integer from 1 to 254 or its decimal digit string, with no signs, spaces or leading zeros.', wrongType: 'styleOrder prop must be a number or a string representing a number.', }, @@ -103,9 +136,11 @@ export const styleOrderRange = createRule({ const object = node.parent const call = object.parent if ( - node.key.type === AST_NODE_TYPES.Identifier && !node.computed && - node.key.name === 'styleOrder' && + ((node.key.type === AST_NODE_TYPES.Identifier && + node.key.name === 'styleOrder') || + (node.key.type === AST_NODE_TYPES.Literal && + node.key.value === 'styleOrder')) && node.value.type !== AST_NODE_TYPES.AssignmentPattern && node.value.type !== AST_NODE_TYPES.TSEmptyBodyFunctionExpression && object.type === AST_NODE_TYPES.ObjectExpression && @@ -120,7 +155,6 @@ export const styleOrderRange = createRule({ if ( node.name.type !== AST_NODE_TYPES.JSXIdentifier || node.name.name !== 'styleOrder' || - !node.value || !STYLE_COMPONENTS.has( componentName(node.parent.name, importStorage) ?? '', ) @@ -128,6 +162,11 @@ export const styleOrderRange = createRule({ return } + if (!node.value) { + context.report({ node, messageId: 'styleOrderRange' }) + return + } + if ( node.value.type === AST_NODE_TYPES.JSXExpressionContainer && node.value.expression.type !== AST_NODE_TYPES.JSXEmptyExpression diff --git a/test-fixtures/style-order.json b/test-fixtures/style-order.json new file mode 100644 index 000000000..182c67a81 --- /dev/null +++ b/test-fixtures/style-order.json @@ -0,0 +1,71 @@ +[ + { "expression": "1", "valid": true }, + { "expression": "254", "valid": true }, + { "expression": "100.0", "valid": true }, + { "expression": "1e2", "valid": true }, + { "expression": "+100", "valid": true }, + { "expression": "1 as const", "valid": true }, + { "expression": "1 satisfies number", "valid": true }, + { "expression": "1!", "valid": true }, + { "expression": "-(-1)", "valid": true }, + { "expression": "+'1'", "valid": true }, + { "expression": "+'01'", "valid": true }, + { "expression": "-'-1'", "valid": true }, + { "expression": "+`1`", "valid": true }, + { "expression": "+`\\u0031`", "valid": true }, + { "expression": "+(1 as const)", "valid": true }, + { "expression": "+(1 satisfies number)", "valid": true }, + { "expression": "+(1!)", "valid": true }, + { "expression": "'1'", "valid": true }, + { "expression": "'254'", "valid": true }, + { "expression": "`100`", "valid": true }, + { "expression": "`\\u0031`", "valid": true }, + { "expression": "on ? 1 : 2", "valid": true }, + { "expression": "on && 2", "valid": true }, + { "expression": "0", "valid": false }, + { "expression": "255", "valid": false }, + { "expression": "256", "valid": false }, + { "expression": "-1", "valid": false }, + { "expression": "1.5", "valid": false }, + { "expression": "'100px'", "valid": false }, + { "expression": "'1foo'", "valid": false }, + { "expression": "'1e2'", "valid": false }, + { "expression": "'100.0'", "valid": false }, + { "expression": "'+100'", "valid": false }, + { "expression": "'01'", "valid": false }, + { "expression": "' 1'", "valid": false }, + { "expression": "'1 '", "valid": false }, + { "expression": "'1\\n'", "valid": false }, + { "expression": "''", "valid": false }, + { "expression": "' '", "valid": false }, + { "expression": "'NaN'", "valid": false }, + { "expression": "'Infinity'", "valid": false }, + { "expression": "null", "valid": false }, + { "expression": "true", "valid": false }, + { "expression": "false", "valid": false }, + { "expression": "undefined", "valid": false }, + { "expression": "void 0", "valid": false }, + { "expression": "!1", "valid": false }, + { "expression": "~1", "valid": false }, + { "expression": "typeof 1", "valid": false }, + { "expression": "+'no-number'", "valid": false }, + { "expression": "+'1\\n'", "valid": true }, + { "expression": "+'0x10'", "valid": true }, + { "expression": "+'1e2'", "valid": true }, + { "expression": "+'1.0'", "valid": true }, + { "expression": "+true", "valid": true }, + { "expression": "+'1.5'", "valid": false }, + { "expression": "+''", "valid": false }, + { "expression": "+' '", "valid": false }, + { "expression": "+'0'", "valid": false }, + { "expression": "+'255'", "valid": false }, + { "expression": "-'1'", "valid": false }, + { "expression": "+'Infinity'", "valid": false }, + { "expression": "+'abc'", "valid": false }, + { "expression": "+null", "valid": false }, + { "expression": "+false", "valid": false }, + { "expression": "on ? 1 : '100px'", "valid": false }, + { "expression": "on && '01'", "valid": false }, + { "expression": "`100px`", "valid": false }, + { "expression": "`01`", "valid": false } +] From 809f8e522c552b02fc6651b1aa73e763bcdb48e2 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 01:26:57 +0900 Subject: [PATCH 4/9] fix(config): validate custom shorthand targets atomically Refs #684 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...epack_log_shorthand_target_validation.json | 13 + bindings/devup-ui-wasm/src/lib.rs | 59 +- libs/css/src/lib.rs | 41 +- libs/css/src/property_names.rs | 174 +++++ libs/css/src/shorthand.rs | 237 ++++++ libs/extractor/src/lib.rs | 2 + libs/extractor/src/shorthand_tests.rs | 169 ++++ packages/plugin-utils/package.json | 3 +- .../src/__tests__/shorthands.test.ts | 190 +++++ packages/plugin-utils/src/index.ts | 7 + packages/plugin-utils/src/property-names.ts | 733 ++++++++++++++++++ packages/plugin-utils/src/shorthands.ts | 121 +++ packages/plugin-utils/src/types.ts | 4 +- 13 files changed, 1719 insertions(+), 34 deletions(-) create mode 100644 .changepacks/changepack_log_shorthand_target_validation.json create mode 100644 libs/css/src/property_names.rs create mode 100644 libs/css/src/shorthand.rs create mode 100644 libs/extractor/src/shorthand_tests.rs create mode 100644 packages/plugin-utils/src/__tests__/shorthands.test.ts create mode 100644 packages/plugin-utils/src/property-names.ts create mode 100644 packages/plugin-utils/src/shorthands.ts diff --git a/.changepacks/changepack_log_shorthand_target_validation.json b/.changepacks/changepack_log_shorthand_target_validation.json new file mode 100644 index 000000000..5bae9f6be --- /dev/null +++ b/.changepacks/changepack_log_shorthand_target_validation.json @@ -0,0 +1,13 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/plugin-utils/package.json": "Patch", + "packages/vite-plugin/package.json": "Patch", + "packages/next-plugin/package.json": "Patch", + "packages/webpack-plugin/package.json": "Patch", + "packages/rsbuild-plugin/package.json": "Patch", + "packages/bun-plugin/package.json": "Patch" + }, + "note": "Custom shorthand targets are constrained to supported camelCase/kebab-case CSS properties, built-in aliases and valid custom properties. All five build adapters normalize options at creation before registration or extraction, including Next.js production cache reuse. Unsupported targets fail registration atomically with the original shorthand name, target and zero-based index; previously registered shorthands remain unchanged. New devup-ui option errors expose structured logical shorthands paths without inventing source coordinates. Eighteen dead unprefixed property names are rejected.", + "date": "2026-10-05T00:00:00.000Z" +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 0a2347ac2..9c775c7b7 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -610,16 +610,17 @@ pub fn register_theme(theme_object: JsValue) -> Result<(), JsValue> { } /// Internal function to register custom style-property shorthands. -pub fn register_shorthands_internal(shorthands: BTreeMap>) { - css::set_custom_shorthands(shorthands); +pub fn register_shorthands_internal( + shorthands: BTreeMap>, +) -> Result<(), css::shorthand::InvalidShorthandTarget> { + css::set_custom_shorthands(shorthands) } #[wasm_bindgen(js_name = "registerShorthands")] #[cfg(not(tarpaulin_include))] pub fn register_shorthands(shorthands: JsValue) -> Result<(), JsValue> { let shorthands = serde_wasm_bindgen::from_value(shorthands).map_err(js_error)?; - register_shorthands_internal(shorthands); - Ok(()) + register_shorthands_internal(shorthands).map_err(js_error) } #[wasm_bindgen(js_name = "getDefaultTheme")] @@ -1401,7 +1402,7 @@ mod tests { #[test] #[serial] fn test_get_theme_interface() { - register_shorthands_internal(BTreeMap::new()); + register_shorthands_internal(BTreeMap::new()).unwrap(); let sheet = StyleSheet::default(); assert_eq!( sheet.create_interface( @@ -1423,7 +1424,8 @@ mod tests { register_shorthands_internal(BTreeMap::from([( "insetX".to_string(), vec!["left".to_string(), "right".to_string()], - )])); + )])) + .unwrap(); assert_eq!( get_theme_interface( "package", @@ -1437,7 +1439,7 @@ mod tests { ); // test wrong case - register_shorthands_internal(BTreeMap::new()); + register_shorthands_internal(BTreeMap::new()).unwrap(); let mut sheet = StyleSheet::default(); let mut theme = Theme::default(); let mut color_theme = ColorTheme::default(); @@ -2021,7 +2023,7 @@ mod tests { )]); register_theme_internal(theme); - register_shorthands_internal(shorthands); + register_shorthands_internal(shorthands).unwrap(); // Verify the theme was registered let default_theme = GLOBAL_STYLE_SHEET.lock().unwrap().theme.get_default_theme(); @@ -2030,6 +2032,45 @@ mod tests { css::disassemble_property("insetX").collect::>(), ["left", "right"] ); - register_shorthands_internal(BTreeMap::new()); + register_shorthands_internal(BTreeMap::new()).unwrap(); + } + + #[test] + #[serial] + fn test_register_shorthands_internal_rejects_targets_atomically() { + register_shorthands_internal(BTreeMap::from([("old".into(), vec!["py".into()])])).unwrap(); + let error = register_shorthands_internal(BTreeMap::from([ + ("aValid".into(), vec!["width".into()]), + ("bad".into(), vec!["height".into(), "widht".into()]), + ])) + .unwrap_err(); + assert_eq!(error.alias, "bad"); + assert_eq!(error.target, "widht"); + assert_eq!(error.index, 1); + assert_eq!(css::get_custom_shorthand_names(), ["old"]); + assert_eq!( + css::disassemble_property("old").collect::>(), + ["padding-top", "padding-bottom"] + ); + register_shorthands_internal(BTreeMap::new()).unwrap(); + } + + #[test] + #[serial] + fn test_register_shorthands_internal_normalizes_vendor_targets() { + register_shorthands_internal(BTreeMap::from([( + "maskAlias".into(), + vec![ + "WebkitMaskImage".into(), + "-webkit-mask-image".into(), + "--Mask".into(), + ], + )])) + .unwrap(); + assert_eq!( + css::disassemble_property("maskAlias").collect::>(), + ["-webkit-mask-image", "-webkit-mask-image", "--Mask"] + ); + register_shorthands_internal(BTreeMap::new()).unwrap(); } } diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 427732ae7..3ab3627ad 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -9,8 +9,10 @@ pub mod is_special_property; mod num_to_nm_base; pub mod optimize_multi_css_value; pub mod optimize_value; +mod property_names; pub mod rm_css_comment; mod selector_separator; +pub mod shorthand; pub mod style_selector; pub mod theme_tokens; pub mod utils; @@ -257,26 +259,18 @@ static CUSTOM_SHORTHANDS: LazyLock>>> = static HAS_CUSTOM_SHORTHANDS: AtomicBool = AtomicBool::new(false); /// Replace the custom shorthand registry used by style extraction. -pub fn set_custom_shorthands(shorthands: BTreeMap>) { - if let Ok(mut registry) = CUSTOM_SHORTHANDS.write() { - let shorthands: BTreeMap> = shorthands - .into_iter() - .map(|(name, properties)| { - let properties = properties - .into_iter() - .flat_map(|property| { - GLOBAL_STYLE_PROPERTY.get(property.as_str()).map_or_else( - || vec![to_kebab_case(&property).into_owned()], - |mapped| mapped.iter().map(|value| (*value).to_string()).collect(), - ) - }) - .collect(); - (name, properties) - }) - .collect(); - HAS_CUSTOM_SHORTHANDS.store(!shorthands.is_empty(), Ordering::Relaxed); - *registry = shorthands; - } +pub fn set_custom_shorthands( + shorthands: BTreeMap>, +) -> Result<(), shorthand::InvalidShorthandTarget> { + let shorthands = shorthand::normalize_shorthands(shorthands)?; + let mut registry = CUSTOM_SHORTHANDS.write().unwrap_or_else(|poisoned| { + CUSTOM_SHORTHANDS.clear_poison(); + poisoned.into_inner() + }); + HAS_CUSTOM_SHORTHANDS.store(!shorthands.is_empty(), Ordering::Relaxed); + *registry = shorthands; + drop(registry); + Ok(()) } #[must_use] @@ -1397,7 +1391,7 @@ mod tests { #[test] #[serial] - fn test_custom_shorthand() { + fn test_custom_shorthand() -> Result<(), shorthand::InvalidShorthandTarget> { set_custom_shorthands(BTreeMap::from([( "insetX".to_string(), vec![ @@ -1405,17 +1399,18 @@ mod tests { "marginRight".to_string(), "py".to_string(), ], - )])); + )]))?; assert_eq!( disassemble_property("insetX").collect::>(), ["left", "margin-right", "padding-top", "padding-bottom"] ); - set_custom_shorthands(BTreeMap::new()); + set_custom_shorthands(BTreeMap::new())?; assert_eq!( disassemble_property("insetX").collect::>(), ["inset-x"] ); + Ok(()) } } diff --git a/libs/css/src/property_names.rs b/libs/css/src/property_names.rs new file mode 100644 index 000000000..4b220880e --- /dev/null +++ b/libs/css/src/property_names.rs @@ -0,0 +1,174 @@ +//! Generated from csstype-extra 0.1.37 Properties; excludes the 18 unprefixed dead names. +//! Source SHA-256: ed9937009a84b96e5699789a3d437e06b00972ea2fb05b7900dd076983946ade + +pub(super) static CSS_PROPERTY_NAMES: phf::Set<&str> = phf::phf_set! { + "-moz-appearance", "-moz-binding", "-moz-border-bottom-colors", "-moz-border-left-colors", + "-moz-border-right-colors", "-moz-border-top-colors", "-moz-box-align", "-moz-box-direction", + "-moz-box-flex", "-moz-box-flex-group", "-moz-box-lines", "-moz-box-ordinal-group", + "-moz-box-orient", "-moz-box-pack", "-moz-context-properties", "-moz-float-edge", + "-moz-force-broken-image-icon", "-moz-orient", "-moz-outline-radius", "-moz-outline-radius-bottomleft", + "-moz-outline-radius-bottomright", "-moz-outline-radius-topleft", "-moz-outline-radius-topright", "-moz-overflow-clip-box", + "-moz-stack-sizing", "-moz-text-blink", "-moz-user-focus", "-moz-user-input", + "-moz-user-modify", "-moz-window-dragging", "-moz-window-shadow", "-ms-accelerator", + "-ms-block-progression", "-ms-content-zoom-chaining", "-ms-content-zoom-limit", "-ms-content-zoom-limit-max", + "-ms-content-zoom-limit-min", "-ms-content-zoom-snap", "-ms-content-zoom-snap-points", "-ms-content-zoom-snap-type", + "-ms-content-zooming", "-ms-filter", "-ms-flow-from", "-ms-flow-into", + "-ms-grid-columns", "-ms-grid-rows", "-ms-high-contrast-adjust", "-ms-hyphenate-limit-chars", + "-ms-hyphenate-limit-lines", "-ms-hyphenate-limit-zone", "-ms-ime-align", "-ms-overflow-style", + "-ms-scroll-chaining", "-ms-scroll-limit", "-ms-scroll-limit-x-max", "-ms-scroll-limit-x-min", + "-ms-scroll-limit-y-max", "-ms-scroll-limit-y-min", "-ms-scroll-rails", "-ms-scroll-snap-points-x", + "-ms-scroll-snap-points-y", "-ms-scroll-snap-type", "-ms-scroll-snap-x", "-ms-scroll-snap-y", + "-ms-scroll-translation", "-ms-scrollbar-arrow-color", "-ms-scrollbar-base-color", "-ms-scrollbar-darkshadow-color", + "-ms-scrollbar-face-color", "-ms-scrollbar-highlight-color", "-ms-scrollbar-shadow-color", "-ms-scrollbar-track-color", + "-ms-scrollbar3dlight-color", "-ms-text-autospace", "-ms-touch-select", "-ms-user-select", + "-ms-wrap-flow", "-ms-wrap-margin", "-ms-wrap-through", "-webkit-appearance", + "-webkit-border-after", "-webkit-border-after-color", "-webkit-border-after-style", "-webkit-border-after-width", + "-webkit-border-before", "-webkit-border-before-color", "-webkit-border-before-style", "-webkit-border-before-width", + "-webkit-border-end", "-webkit-border-end-color", "-webkit-border-end-style", "-webkit-border-end-width", + "-webkit-border-start", "-webkit-border-start-color", "-webkit-border-start-style", "-webkit-border-start-width", + "-webkit-box-align", "-webkit-box-direction", "-webkit-box-flex", "-webkit-box-flex-group", + "-webkit-box-lines", "-webkit-box-ordinal-group", "-webkit-box-orient", "-webkit-box-pack", + "-webkit-box-reflect", "-webkit-line-clamp", "-webkit-mask", "-webkit-mask-attachment", + "-webkit-mask-clip", "-webkit-mask-composite", "-webkit-mask-image", "-webkit-mask-origin", + "-webkit-mask-position", "-webkit-mask-position-x", "-webkit-mask-position-y", "-webkit-mask-repeat", + "-webkit-mask-repeat-x", "-webkit-mask-repeat-y", "-webkit-mask-size", "-webkit-overflow-scrolling", + "-webkit-tap-highlight-color", "-webkit-text-fill-color", "-webkit-text-stroke", "-webkit-text-stroke-color", + "-webkit-text-stroke-width", "-webkit-touch-callout", "-webkit-user-modify", "-webkit-user-select", + "accent-color", "align-content", "align-items", "align-self", + "align-tracks", "alignment-baseline", "all", "anchor-name", + "anchor-scope", "animation", "animation-composition", "animation-delay", + "animation-direction", "animation-duration", "animation-fill-mode", "animation-iteration-count", + "animation-name", "animation-play-state", "animation-range", "animation-range-end", + "animation-range-start", "animation-timeline", "animation-timing-function", "animation-trigger", + "appearance", "aspect-ratio", "backdrop-filter", "backface-visibility", + "background", "background-attachment", "background-blend-mode", "background-clip", + "background-color", "background-image", "background-origin", "background-position", + "background-position-x", "background-position-y", "background-repeat", "background-size", + "baseline-shift", "baseline-source", "block-size", "border", + "border-block", "border-block-color", "border-block-end", "border-block-end-color", + "border-block-end-style", "border-block-end-width", "border-block-start", "border-block-start-color", + "border-block-start-style", "border-block-start-width", "border-block-style", "border-block-width", + "border-bottom", "border-bottom-color", "border-bottom-left-radius", "border-bottom-right-radius", + "border-bottom-style", "border-bottom-width", "border-collapse", "border-color", + "border-end-end-radius", "border-end-start-radius", "border-image", "border-image-outset", + "border-image-repeat", "border-image-slice", "border-image-source", "border-image-width", + "border-inline", "border-inline-color", "border-inline-end", "border-inline-end-color", + "border-inline-end-style", "border-inline-end-width", "border-inline-start", "border-inline-start-color", + "border-inline-start-style", "border-inline-start-width", "border-inline-style", "border-inline-width", + "border-left", "border-left-color", "border-left-style", "border-left-width", + "border-radius", "border-right", "border-right-color", "border-right-style", + "border-right-width", "border-shape", "border-spacing", "border-start-end-radius", + "border-start-start-radius", "border-style", "border-top", "border-top-color", + "border-top-left-radius", "border-top-right-radius", "border-top-style", "border-top-width", + "border-width", "bottom", "box-decoration-break", "box-shadow", + "box-sizing", "break-after", "break-before", "break-inside", + "caption-side", "caret", "caret-animation", "caret-color", + "caret-shape", "clear", "clip", "clip-path", + "clip-rule", "color", "color-interpolation-filters", "color-scheme", + "column-count", "column-fill", "column-gap", "column-height", + "column-rule", "column-rule-color", "column-rule-style", "column-rule-width", + "column-span", "column-width", "column-wrap", "columns", + "contain", "contain-intrinsic-block-size", "contain-intrinsic-height", "contain-intrinsic-inline-size", + "contain-intrinsic-size", "contain-intrinsic-width", "container", "container-name", + "container-type", "content", "content-visibility", "corner-block-end-shape", + "corner-block-start-shape", "corner-bottom-left-shape", "corner-bottom-right-shape", "corner-bottom-shape", + "corner-end-end-shape", "corner-end-start-shape", "corner-inline-end-shape", "corner-inline-start-shape", + "corner-left-shape", "corner-right-shape", "corner-shape", "corner-start-end-shape", + "corner-start-start-shape", "corner-top-left-shape", "corner-top-right-shape", "corner-top-shape", + "counter-increment", "counter-reset", "counter-set", "cursor", + "cx", "cy", "d", "direction", + "display", "dominant-baseline", "dynamic-range-limit", "empty-cells", + "field-sizing", "fill", "fill-opacity", "fill-rule", + "filter", "flex", "flex-basis", "flex-direction", + "flex-flow", "flex-grow", "flex-line-count", "flex-shrink", + "flex-wrap", "float", "flood-color", "flood-opacity", + "font", "font-family", "font-feature-settings", "font-kerning", + "font-language-override", "font-optical-sizing", "font-palette", "font-size", + "font-size-adjust", "font-smooth", "font-stretch", "font-style", + "font-synthesis", "font-synthesis-position", "font-synthesis-small-caps", "font-synthesis-style", + "font-synthesis-weight", "font-variant", "font-variant-alternates", "font-variant-caps", + "font-variant-east-asian", "font-variant-emoji", "font-variant-ligatures", "font-variant-numeric", + "font-variant-position", "font-variation-settings", "font-weight", "font-width", + "forced-color-adjust", "frame-sizing", "gap", "grid", + "grid-area", "grid-auto-columns", "grid-auto-flow", "grid-auto-rows", + "grid-column", "grid-column-end", "grid-column-gap", "grid-column-start", + "grid-gap", "grid-row", "grid-row-end", "grid-row-gap", + "grid-row-start", "grid-template", "grid-template-areas", "grid-template-columns", + "grid-template-rows", "hanging-punctuation", "height", "hyphenate-character", + "hyphenate-limit-chars", "hyphens", "image-orientation", "image-rendering", + "image-resolution", "ime-mode", "initial-letter", "initial-letter-align", + "inline-size", "inset", "inset-block", "inset-block-end", + "inset-block-start", "inset-inline", "inset-inline-end", "inset-inline-start", + "interactivity", "interest-delay", "interest-delay-end", "interest-delay-start", + "interpolate-size", "isolation", "justify-content", "justify-items", + "justify-self", "justify-tracks", "left", "letter-spacing", + "lighting-color", "line-break", "line-clamp", "line-height", + "line-height-step", "link-parameters", "list-style", "list-style-image", + "list-style-position", "list-style-type", "margin", "margin-block", + "margin-block-end", "margin-block-start", "margin-bottom", "margin-inline", + "margin-inline-end", "margin-inline-start", "margin-left", "margin-right", + "margin-top", "margin-trim", "marker", "marker-end", + "marker-mid", "marker-start", "mask", "mask-border", + "mask-border-mode", "mask-border-outset", "mask-border-repeat", "mask-border-slice", + "mask-border-source", "mask-border-width", "mask-clip", "mask-composite", + "mask-image", "mask-mode", "mask-origin", "mask-position", + "mask-repeat", "mask-size", "mask-type", "masonry-auto-flow", + "math-depth", "math-shift", "math-style", "max-block-size", + "max-height", "max-inline-size", "max-lines", "max-width", + "min-block-size", "min-height", "min-inline-size", "min-width", + "mix-blend-mode", "object-fit", "object-position", "object-view-box", + "offset", "offset-anchor", "offset-distance", "offset-path", + "offset-position", "offset-rotate", "opacity", "order", + "orphans", "outline", "outline-color", "outline-offset", + "outline-style", "outline-width", "overflow", "overflow-anchor", + "overflow-block", "overflow-clip-box", "overflow-clip-margin", "overflow-inline", + "overflow-wrap", "overflow-x", "overflow-y", "overlay", + "overscroll-behavior", "overscroll-behavior-block", "overscroll-behavior-inline", "overscroll-behavior-x", + "overscroll-behavior-y", "padding", "padding-block", "padding-block-end", + "padding-block-start", "padding-bottom", "padding-inline", "padding-inline-end", + "padding-inline-start", "padding-left", "padding-right", "padding-top", + "page", "page-break-after", "page-break-before", "page-break-inside", + "paint-order", "path-length", "perspective", "perspective-origin", + "place-content", "place-items", "place-self", "pointer-events", + "position", "position-anchor", "position-area", "position-try", + "position-try-fallbacks", "position-try-order", "position-visibility", "print-color-adjust", + "quotes", "r", "reading-flow", "reading-order", + "resize", "right", "rotate", "row-gap", + "ruby-align", "ruby-merge", "ruby-overhang", "ruby-position", + "rx", "ry", "scale", "scroll-axis-lock", + "scroll-behavior", "scroll-initial-target", "scroll-margin", "scroll-margin-block", + "scroll-margin-block-end", "scroll-margin-block-start", "scroll-margin-bottom", "scroll-margin-inline", + "scroll-margin-inline-end", "scroll-margin-inline-start", "scroll-margin-left", "scroll-margin-right", + "scroll-margin-top", "scroll-marker-group", "scroll-padding", "scroll-padding-block", + "scroll-padding-block-end", "scroll-padding-block-start", "scroll-padding-bottom", "scroll-padding-inline", + "scroll-padding-inline-end", "scroll-padding-inline-start", "scroll-padding-left", "scroll-padding-right", + "scroll-padding-top", "scroll-snap-align", "scroll-snap-stop", "scroll-snap-type", + "scroll-target-group", "scroll-timeline", "scroll-timeline-axis", "scroll-timeline-name", + "scrollbar-color", "scrollbar-gutter", "scrollbar-width", "shape-image-threshold", + "shape-margin", "shape-outside", "shape-rendering", "speak-as", + "stop-color", "stop-opacity", "stroke", "stroke-color", + "stroke-dasharray", "stroke-dashoffset", "stroke-linecap", "stroke-linejoin", + "stroke-miterlimit", "stroke-opacity", "stroke-width", "tab-size", + "table-layout", "text-align", "text-align-last", "text-anchor", + "text-autospace", "text-box", "text-box-edge", "text-box-trim", + "text-combine-upright", "text-decoration", "text-decoration-color", "text-decoration-inset", + "text-decoration-line", "text-decoration-skip", "text-decoration-skip-ink", "text-decoration-style", + "text-decoration-thickness", "text-emphasis", "text-emphasis-color", "text-emphasis-position", + "text-emphasis-style", "text-fit", "text-indent", "text-justify", + "text-orientation", "text-overflow", "text-rendering", "text-shadow", + "text-size-adjust", "text-spacing-trim", "text-transform", "text-underline-offset", + "text-underline-position", "text-wrap", "text-wrap-mode", "text-wrap-style", + "timeline-scope", "timeline-trigger", "timeline-trigger-activation-range", "timeline-trigger-activation-range-end", + "timeline-trigger-activation-range-start", "timeline-trigger-active-range", "timeline-trigger-active-range-end", "timeline-trigger-active-range-start", + "timeline-trigger-name", "timeline-trigger-source", "top", "touch-action", + "transform", "transform-box", "transform-origin", "transform-style", + "transition", "transition-behavior", "transition-delay", "transition-duration", + "transition-property", "transition-timing-function", "translate", "trigger-scope", + "unicode-bidi", "user-select", "vector-effect", "vertical-align", + "view-timeline", "view-timeline-axis", "view-timeline-inset", "view-timeline-name", + "view-transition-class", "view-transition-name", "view-transition-scope", "visibility", + "white-space", "white-space-collapse", "widows", "width", + "will-change", "window-drag", "word-break", "word-spacing", + "word-wrap", "writing-mode", "x", "y", + "z-index", "zoom", +}; diff --git a/libs/css/src/shorthand.rs b/libs/css/src/shorthand.rs new file mode 100644 index 000000000..a77f6f6bd --- /dev/null +++ b/libs/css/src/shorthand.rs @@ -0,0 +1,237 @@ +use std::{collections::BTreeMap, fmt}; + +use crate::{ + constant::GLOBAL_STYLE_PROPERTY, + property_names::CSS_PROPERTY_NAMES, + utils::{to_camel_case, to_kebab_case}, +}; + +/// A rejected target and its logical position in shorthand configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InvalidShorthandTarget { + /// The configured shorthand name. + pub alias: String, + /// The original target spelling, before normalization. + pub target: String, + /// The zero-based position in the target list. + pub index: usize, +} + +impl fmt::Display for InvalidShorthandTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "shorthands[{:?}][{}]: shorthand `{}` cannot use `{}` at build time: its targets must be supported CSS properties, built-in aliases or custom properties", + self.alias, self.index, self.alias, self.target + ) + } +} + +impl std::error::Error for InvalidShorthandTarget {} + +fn is_custom_property(property: &str) -> bool { + property.strip_prefix("--").is_some_and(|name| { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-') || c >= '\u{80}') + }) +} + +fn normalize_property(property: &str) -> Option { + if is_custom_property(property) || CSS_PROPERTY_NAMES.contains(property) { + return Some(property.to_string()); + } + let kebab = to_kebab_case(property); + let canonical = if ["Webkit", "Moz", "ms", "O"].iter().any(|prefix| { + property.strip_prefix(prefix).is_some_and(|suffix| { + suffix + .as_bytes() + .first() + .is_some_and(u8::is_ascii_uppercase) + }) + }) { + format!("-{kebab}") + } else { + kebab.into_owned() + }; + if !CSS_PROPERTY_NAMES.contains(canonical.as_str()) { + return None; + } + let camel = to_camel_case(&canonical); + let expected = camel + .strip_prefix("Ms") + .map_or_else(|| camel.to_string(), |suffix| format!("ms{suffix}")); + (property == expected).then_some(canonical) +} + +/// Validate every target before producing a normalized registry. +/// +/// # Errors +/// Returns the original alias, target and index of the first unsupported target. +pub fn normalize_shorthands( + shorthands: BTreeMap>, +) -> Result>, InvalidShorthandTarget> { + shorthands + .into_iter() + .map(|(alias, targets)| { + let mut normalized = Vec::new(); + for (index, target) in targets.into_iter().enumerate() { + if let Some(properties) = GLOBAL_STYLE_PROPERTY.get(target.as_str()) { + normalized.extend(properties.iter().map(|property| (*property).to_string())); + } else { + normalized.push(normalize_property(&target).ok_or_else(|| { + InvalidShorthandTarget { + alias: alias.clone(), + target, + index, + } + })?); + } + } + Ok((alias, normalized)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use serial_test::serial; + + #[rstest] + #[case("marginRight", Some("margin-right"))] + #[case("margin-right", Some("margin-right"))] + #[case("left", Some("left"))] + #[case("WebkitMaskImage", Some("-webkit-mask-image"))] + #[case("-webkit-mask-image", Some("-webkit-mask-image"))] + #[case("MozAppearance", Some("-moz-appearance"))] + #[case("msUserSelect", Some("-ms-user-select"))] + #[case("-ms-user-select", Some("-ms-user-select"))] + #[case("OTransform", None)] + #[case("--My-color", Some("--My-color"))] + #[case("--1", Some("--1"))] + #[case("--색", Some("--색"))] + #[case("--", None)] + #[case("--bad name", None)] + #[case("width ", None)] + #[case("Width", None)] + #[case("widht", None)] + #[case("scrollMargnLeft", None)] + #[case("webkitMaskImage", None)] + #[case("MsUserSelect", None)] + #[case("styleOrder", None)] + #[case("_hover", None)] + #[case("positioning", None)] + #[case("", None)] + fn validates_spelling(#[case] property: &str, #[case] expected: Option<&str>) { + assert_eq!(normalize_property(property).as_deref(), expected); + } + + #[test] + fn rejects_unprefixed_dead_properties() { + for property in [ + "box-align", + "box-pack", + "box-flex", + "box-flex-group", + "box-orient", + "box-ordinal-group", + "box-direction", + "box-lines", + "flex-order", + "flex-positive", + "flex-negative", + "flex-preferred-size", + "scroll-snap-coordinate", + "scroll-snap-destination", + "scroll-snap-points-x", + "scroll-snap-points-y", + "scroll-snap-type-x", + "scroll-snap-type-y", + ] { + assert_eq!(normalize_property(property), None); + assert_eq!(normalize_property(&to_camel_case(property)), None); + } + } + + #[test] + fn preserves_builtin_order_and_empty_lists() -> Result<(), InvalidShorthandTarget> { + let normalized = normalize_shorthands(BTreeMap::from([ + ( + "edges".into(), + vec![ + "left".into(), + "py".into(), + "margin-right".into(), + "--Gap".into(), + ], + ), + ("empty".into(), vec![]), + ]))?; + assert_eq!( + normalized["edges"], + [ + "left", + "padding-top", + "padding-bottom", + "margin-right", + "--Gap" + ] + ); + assert_eq!(normalized["empty"], Vec::::new()); + Ok(()) + } + + #[test] + #[serial] + fn invalid_map_preserves_previous_registry() -> Result<(), InvalidShorthandTarget> { + crate::set_custom_shorthands(BTreeMap::from([("old".into(), vec!["left".into()])]))?; + let error = match crate::set_custom_shorthands(BTreeMap::from([ + ("aValid".into(), vec!["right".into()]), + ("bad".into(), vec!["width".into(), "widht".into()]), + ])) { + Err(error) => error, + Ok(()) => panic!("expected invalid shorthand registration to fail"), + }; + assert_eq!( + error, + InvalidShorthandTarget { + alias: "bad".into(), + target: "widht".into(), + index: 1 + } + ); + assert_eq!( + error.to_string(), + "shorthands[\"bad\"][1]: shorthand `bad` cannot use `widht` at build time: its targets must be supported CSS properties, built-in aliases or custom properties" + ); + assert_eq!(crate::get_custom_shorthand_names(), ["old"]); + assert_eq!( + crate::disassemble_property("old").collect::>(), + ["left"] + ); + crate::set_custom_shorthands(BTreeMap::new())?; + Ok(()) + } + + #[test] + #[serial] + fn registration_recovers_a_poisoned_registry() -> Result<(), InvalidShorthandTarget> { + match std::panic::catch_unwind(|| -> Result<(), Box> { + let _registry = crate::CUSTOM_SHORTHANDS.write()?; + panic!("poison the shorthand registry"); + }) { + Err(_) => {} + Ok(_) => panic!("expected the registry poisoning closure to panic"), + } + crate::set_custom_shorthands(BTreeMap::from([("recovered".into(), vec!["right".into()])]))?; + assert_eq!( + crate::disassemble_property("recovered").collect::>(), + ["right"] + ); + crate::set_custom_shorthands(BTreeMap::new())?; + Ok(()) + } +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 05998e2d6..704e77834 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -14,6 +14,8 @@ mod module_loader; mod mutations; mod prop_modify_utils; mod prop_valid; +#[cfg(test)] +mod shorthand_tests; mod source_map; mod style_order; #[cfg(test)] diff --git a/libs/extractor/src/shorthand_tests.rs b/libs/extractor/src/shorthand_tests.rs new file mode 100644 index 000000000..91842b2d7 --- /dev/null +++ b/libs/extractor/src/shorthand_tests.rs @@ -0,0 +1,169 @@ +use std::collections::BTreeMap; + +use css::{class_map::reset_class_map, file_map::reset_file_map, set_custom_shorthands}; +use serial_test::serial; + +use crate::{ + ExtractOption, ExtractOutput, extract, extract_style::extract_style_value::ExtractStyleValue, +}; + +fn extract_element(element: &str) -> Result> { + reset_class_map(); + reset_file_map(); + extract( + "shorthand.tsx", + &format!("import {{ Box }} from '@devup-ui/react'; export const element = {element};"), + ExtractOption { + package: "@devup-ui/react".into(), + single_css: true, + import_main_css: false, + ..ExtractOption::default() + }, + ) +} + +fn static_properties(output: &ExtractOutput) -> Vec<(String, String, u8)> { + let mut styles: Vec<_> = output + .styles + .iter() + .map(|value| { + let ExtractStyleValue::Static(style) = value else { + panic!("expected static shorthand styles") + }; + (style.property.clone(), style.value.clone(), style.level) + }) + .collect(); + styles.sort(); + styles +} + +#[test] +#[serial] +fn shorthand_expands_builtin_and_camel_targets() -> Result<(), Box> { + set_custom_shorthands(BTreeMap::from([( + "insetX".into(), + vec!["left".into(), "marginRight".into(), "py".into()], + )]))?; + let output = extract_element("")?; + assert_eq!( + static_properties(&output), + vec![ + ("left".into(), "7px".into(), 0), + ("margin-right".into(), "7px".into(), 0), + ("padding-bottom".into(), "7px".into(), 0), + ("padding-top".into(), "7px".into(), 0), + ] + ); + assert!(!output.code.contains("insetX=")); + assert!(output.code.contains("className=")); + set_custom_shorthands(BTreeMap::new())?; + Ok(()) +} + +#[test] +#[serial] +fn shorthand_preserves_responsive_levels_and_hover() -> Result<(), Box> { + set_custom_shorthands(BTreeMap::from([( + "insetX".into(), + vec!["left".into(), "right".into()], + )]))?; + let output = extract_element("")?; + assert_eq!( + static_properties(&output), + vec![ + ("left".into(), "1px".into(), 0), + ("left".into(), "3px".into(), 2), + ("right".into(), "1px".into(), 0), + ("right".into(), "3px".into(), 2), + ] + ); + for value in &output.styles { + let ExtractStyleValue::Static(style) = value else { + panic!("expected static hover styles") + }; + assert_eq!( + style + .selector + .as_ref() + .map(|selector| selector.as_class_str().into_owned()), + Some("&:hover".into()) + ); + } + set_custom_shorthands(BTreeMap::new())?; + Ok(()) +} + +#[test] +#[serial] +fn shorthand_yields_to_later_explicit_target() -> Result<(), Box> { + set_custom_shorthands(BTreeMap::from([( + "insetX".into(), + vec!["left".into(), "right".into()], + )]))?; + let output = extract_element("")?; + assert_eq!( + static_properties(&output), + vec![ + ("left".into(), "9px".into(), 0), + ("right".into(), "1px".into(), 0) + ] + ); + set_custom_shorthands(BTreeMap::new())?; + Ok(()) +} + +#[test] +#[serial] +fn shorthand_dynamic_values_stay_on_the_element() -> Result<(), Box> { + set_custom_shorthands(BTreeMap::from([( + "insetX".into(), + vec!["left".into(), "right".into()], + )]))?; + let output = extract_element("")?; + let mut properties: Vec<_> = output + .styles + .iter() + .map(|value| { + let ExtractStyleValue::Dynamic(style) = value else { + panic!("expected dynamic shorthand styles") + }; + assert_eq!(style.identifier(), "position"); + style.property().to_string() + }) + .collect(); + properties.sort(); + assert_eq!(properties, ["left", "right"]); + assert!(output.code.contains("style=")); + assert!(!output.code.contains("insetX=")); + set_custom_shorthands(BTreeMap::new())?; + Ok(()) +} + +#[test] +#[serial] +fn rejected_registration_cannot_change_extraction() -> Result<(), Box> { + set_custom_shorthands(BTreeMap::from([( + "insetX".into(), + vec!["left".into(), "right".into()], + )]))?; + let error = match set_custom_shorthands(BTreeMap::from([( + "insetX".into(), + vec!["width".into(), "widht".into()], + )])) { + Err(error) => error, + Ok(()) => panic!("expected invalid shorthand registration to fail"), + }; + assert_eq!(error.alias, "insetX"); + assert_eq!(error.target, "widht"); + assert_eq!(error.index, 1); + let output = extract_element("")?; + assert_eq!( + static_properties(&output), + vec![ + ("left".into(), "4px".into(), 0), + ("right".into(), "4px".into(), 0) + ] + ); + set_custom_shorthands(BTreeMap::new())?; + Ok(()) +} diff --git a/packages/plugin-utils/package.json b/packages/plugin-utils/package.json index 7a818122e..dda8c11fd 100644 --- a/packages/plugin-utils/package.json +++ b/packages/plugin-utils/package.json @@ -18,7 +18,8 @@ "version": "1.0.16", "scripts": { "lint": "eslint", - "build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external" + "test:types": "bun ../../node_modules/@typescript/native/bin/tsc --ignoreConfig --noEmit --strict --skipLibCheck --moduleResolution bundler --module esnext --target esnext --types bun src/__tests__/shorthands.test.ts", + "build": "bun run test:types && bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external" }, "publishConfig": { "access": "public" diff --git a/packages/plugin-utils/src/__tests__/shorthands.test.ts b/packages/plugin-utils/src/__tests__/shorthands.test.ts new file mode 100644 index 000000000..bdf90e617 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/shorthands.test.ts @@ -0,0 +1,190 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'bun:test' + +import { + BUILTIN_SHORTHAND_TARGETS, + CSS_PROPERTY_NAMES, +} from '../property-names' +import { + type CustomShorthandTarget, + isCustomShorthandTarget, + normalizeShorthands, + ShorthandConfigError, +} from '../shorthands' +import type { CustomShorthands } from '../types' + +type Accepted = T extends CustomShorthandTarget ? true : false +type ConfigAccepted = T extends CustomShorthands ? true : false +const configProof: [ + ConfigAccepted<{ bgx: readonly ['backgroundColour'] }>, + ConfigAccepted<{ bgx: readonly ['backgroundColor'] }>, + ConfigAccepted<{ bgx: readonly ['background-color', 'py'] }>, +] = [false, true, true] +const typeProof: [ + Accepted<'marginRight'>, + Accepted<'margin-right'>, + Accepted<'py'>, + Accepted<'-webkit-mask-image'>, + Accepted<'--Gap'>, + Accepted<'widht'>, + Accepted<'backgroundColour'>, + Accepted<'styleOrder'>, + Accepted<'_hover'>, + Accepted<'boxAlign'>, + Accepted<'box-align'>, +] = [true, true, true, true, true, false, false, false, false, false, false] + +describe('shorthand targets', () => { + it('constrains configuration target types', () => { + const config = { + edges: ['left', 'py', 'margin-right', 'WebkitMaskImage', '--Gap'], + } as const satisfies CustomShorthands + expect(normalizeShorthands(config)).toEqual({ + edges: [ + 'left', + 'padding-top', + 'padding-bottom', + 'margin-right', + '-webkit-mask-image', + '--Gap', + ], + }) + expect(typeProof).toEqual([ + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + ]) + expect(configProof).toEqual([false, true, true]) + }) + + it('preserves readonly input, custom property case and empty lists', () => { + const input = Object.freeze({ + z: Object.freeze(['--My-color', '--1', '--색']), + empty: Object.freeze([]), + }) + expect(normalizeShorthands(input)).toEqual({ + empty: [], + z: ['--My-color', '--1', '--색'], + }) + expect(Object.keys(normalizeShorthands(input))).toEqual(['empty', 'z']) + }) + + it.each([ + 'marginRight', + 'margin-right', + 'WebkitMaskImage', + '-webkit-mask-image', + 'MozAppearance', + '-moz-appearance', + 'msUserSelect', + '-ms-user-select', + 'py', + ])('accepts the exact supported spelling %s', (target) => { + expect(isCustomShorthandTarget(target)).toBe(true) + }) + + it.each([ + 'widht', + 'scrollMargnLeft', + 'Width', + 'webkitMaskImage', + 'MsUserSelect', + 'OTransform', + '_hover', + 'styleOrder', + 'positioning', + 'otherAlias', + '', + ' width', + '--', + '--bad name', + 1, + null, + ])('rejects an unsupported target %s', (target) => { + expect(isCustomShorthandTarget(target)).toBe(false) + expect(() => normalizeShorthands({ bad: ['width', target] })).toThrow( + ShorthandConfigError, + ) + }) + + it('retains original target and logical configuration location', () => { + const input = { aValid: ['right'], insetX: ['left', 'widht'] } + try { + normalizeShorthands(input) + throw new Error('invalid target was accepted') + } catch (error) { + if (!(error instanceof ShorthandConfigError)) throw error + expect(error.alias).toBe('insetX') + expect(error.index).toBe(1) + expect(error.target).toBe('widht') + expect(error.path).toBe('shorthands["insetX"][1]') + expect(error.name).toBe('ShorthandConfigError') + expect(error.message).toBe( + 'devup-ui option shorthands["insetX"][1]: shorthand "insetX" cannot use "widht" at build time: its targets must be supported CSS properties, built-in aliases or custom properties', + ) + } + expect(input).toEqual({ aValid: ['right'], insetX: ['left', 'widht'] }) + }) + + it.each([[null], [[]], ['bad'], [1]])( + 'rejects a malformed map %s', + (input) => { + expect(() => normalizeShorthands(input)).toThrow(ShorthandConfigError) + }, + ) + + it('rejects nonarray target lists', () => { + expect(() => normalizeShorthands({ insetX: 'left' })).toThrow( + ShorthandConfigError, + ) + }) + + it('keeps Rust canonical names and builtin expansions consistent', () => { + const rust = readFileSync( + new URL('../../../../libs/css/src/property_names.rs', import.meta.url), + 'utf8', + ) + const canonical = [...rust.matchAll(/"([^"]+)"/g)] + .map((match) => match[1]) + .sort() + const normalized = normalizeShorthands({ names: CSS_PROPERTY_NAMES }) + expect([...normalized.names].sort()).toEqual(canonical) + const aliases = readFileSync( + new URL('../../../../libs/css/src/constant.rs', import.meta.url), + 'utf8', + ).split('pub(super) static GLOBAL_ENUM_STYLE_PROPERTY')[0] + const expansions = Object.fromEntries( + [...aliases.matchAll(/"([^"]+)" => &\[([^\]]+)\]/g)].map((match) => [ + match[1], + [...match[2].matchAll(/"([^"]+)"/g)].map((value) => value[1]), + ]), + ) + const builtinTargets: Record = + BUILTIN_SHORTHAND_TARGETS + expect(builtinTargets).toEqual(expansions) + }) + + it('rejects all eighteen unprefixed dead names', () => { + for (const target of 'box-align,box-pack,box-flex,box-flex-group,box-orient,box-ordinal-group,box-direction,box-lines,flex-order,flex-positive,flex-negative,flex-preferred-size,scroll-snap-coordinate,scroll-snap-destination,scroll-snap-points-x,scroll-snap-points-y,scroll-snap-type-x,scroll-snap-type-y'.split( + ',', + )) { + expect(isCustomShorthandTarget(target)).toBe(false) + expect( + isCustomShorthandTarget( + target.replace(/-([a-z])/g, (_, letter: string) => + letter.toUpperCase(), + ), + ), + ).toBe(false) + } + }) +}) diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index bdca8063f..d1113cce5 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -26,6 +26,13 @@ export { type DevupUIBasePluginOptions, getFileNumByFilename, } from './shared' +export { + type CustomShorthandTarget, + isCustomShorthandTarget, + normalizeShorthands, + ShorthandConfigError, + type ShorthandErrorLocation, +} from './shorthands' export type { CustomShorthands, DevupConfig, diff --git a/packages/plugin-utils/src/property-names.ts b/packages/plugin-utils/src/property-names.ts new file mode 100644 index 000000000..57b4c6e3b --- /dev/null +++ b/packages/plugin-utils/src/property-names.ts @@ -0,0 +1,733 @@ +// Generated from csstype-extra 0.1.37 Properties; excludes the 18 unprefixed dead names. +// Source SHA-256: ed9937009a84b96e5699789a3d437e06b00972ea2fb05b7900dd076983946ade +export const CSS_PROPERTY_NAMES = [ + 'MozAppearance', + 'MozBinding', + 'MozBorderBottomColors', + 'MozBorderLeftColors', + 'MozBorderRightColors', + 'MozBorderTopColors', + 'MozBoxAlign', + 'MozBoxDirection', + 'MozBoxFlex', + 'MozBoxFlexGroup', + 'MozBoxLines', + 'MozBoxOrdinalGroup', + 'MozBoxOrient', + 'MozBoxPack', + 'MozContextProperties', + 'MozFloatEdge', + 'MozForceBrokenImageIcon', + 'MozOrient', + 'MozOutlineRadius', + 'MozOutlineRadiusBottomleft', + 'MozOutlineRadiusBottomright', + 'MozOutlineRadiusTopleft', + 'MozOutlineRadiusTopright', + 'MozOverflowClipBox', + 'MozStackSizing', + 'MozTextBlink', + 'MozUserFocus', + 'MozUserInput', + 'MozUserModify', + 'MozWindowDragging', + 'MozWindowShadow', + 'WebkitAppearance', + 'WebkitBorderAfter', + 'WebkitBorderAfterColor', + 'WebkitBorderAfterStyle', + 'WebkitBorderAfterWidth', + 'WebkitBorderBefore', + 'WebkitBorderBeforeColor', + 'WebkitBorderBeforeStyle', + 'WebkitBorderBeforeWidth', + 'WebkitBorderEnd', + 'WebkitBorderEndColor', + 'WebkitBorderEndStyle', + 'WebkitBorderEndWidth', + 'WebkitBorderStart', + 'WebkitBorderStartColor', + 'WebkitBorderStartStyle', + 'WebkitBorderStartWidth', + 'WebkitBoxAlign', + 'WebkitBoxDirection', + 'WebkitBoxFlex', + 'WebkitBoxFlexGroup', + 'WebkitBoxLines', + 'WebkitBoxOrdinalGroup', + 'WebkitBoxOrient', + 'WebkitBoxPack', + 'WebkitBoxReflect', + 'WebkitLineClamp', + 'WebkitMask', + 'WebkitMaskAttachment', + 'WebkitMaskClip', + 'WebkitMaskComposite', + 'WebkitMaskImage', + 'WebkitMaskOrigin', + 'WebkitMaskPosition', + 'WebkitMaskPositionX', + 'WebkitMaskPositionY', + 'WebkitMaskRepeat', + 'WebkitMaskRepeatX', + 'WebkitMaskRepeatY', + 'WebkitMaskSize', + 'WebkitOverflowScrolling', + 'WebkitTapHighlightColor', + 'WebkitTextFillColor', + 'WebkitTextStroke', + 'WebkitTextStrokeColor', + 'WebkitTextStrokeWidth', + 'WebkitTouchCallout', + 'WebkitUserModify', + 'WebkitUserSelect', + 'accentColor', + 'alignContent', + 'alignItems', + 'alignSelf', + 'alignTracks', + 'alignmentBaseline', + 'all', + 'anchorName', + 'anchorScope', + 'animation', + 'animationComposition', + 'animationDelay', + 'animationDirection', + 'animationDuration', + 'animationFillMode', + 'animationIterationCount', + 'animationName', + 'animationPlayState', + 'animationRange', + 'animationRangeEnd', + 'animationRangeStart', + 'animationTimeline', + 'animationTimingFunction', + 'animationTrigger', + 'appearance', + 'aspectRatio', + 'backdropFilter', + 'backfaceVisibility', + 'background', + 'backgroundAttachment', + 'backgroundBlendMode', + 'backgroundClip', + 'backgroundColor', + 'backgroundImage', + 'backgroundOrigin', + 'backgroundPosition', + 'backgroundPositionX', + 'backgroundPositionY', + 'backgroundRepeat', + 'backgroundSize', + 'baselineShift', + 'baselineSource', + 'blockSize', + 'border', + 'borderBlock', + 'borderBlockColor', + 'borderBlockEnd', + 'borderBlockEndColor', + 'borderBlockEndStyle', + 'borderBlockEndWidth', + 'borderBlockStart', + 'borderBlockStartColor', + 'borderBlockStartStyle', + 'borderBlockStartWidth', + 'borderBlockStyle', + 'borderBlockWidth', + 'borderBottom', + 'borderBottomColor', + 'borderBottomLeftRadius', + 'borderBottomRightRadius', + 'borderBottomStyle', + 'borderBottomWidth', + 'borderCollapse', + 'borderColor', + 'borderEndEndRadius', + 'borderEndStartRadius', + 'borderImage', + 'borderImageOutset', + 'borderImageRepeat', + 'borderImageSlice', + 'borderImageSource', + 'borderImageWidth', + 'borderInline', + 'borderInlineColor', + 'borderInlineEnd', + 'borderInlineEndColor', + 'borderInlineEndStyle', + 'borderInlineEndWidth', + 'borderInlineStart', + 'borderInlineStartColor', + 'borderInlineStartStyle', + 'borderInlineStartWidth', + 'borderInlineStyle', + 'borderInlineWidth', + 'borderLeft', + 'borderLeftColor', + 'borderLeftStyle', + 'borderLeftWidth', + 'borderRadius', + 'borderRight', + 'borderRightColor', + 'borderRightStyle', + 'borderRightWidth', + 'borderShape', + 'borderSpacing', + 'borderStartEndRadius', + 'borderStartStartRadius', + 'borderStyle', + 'borderTop', + 'borderTopColor', + 'borderTopLeftRadius', + 'borderTopRightRadius', + 'borderTopStyle', + 'borderTopWidth', + 'borderWidth', + 'bottom', + 'boxDecorationBreak', + 'boxShadow', + 'boxSizing', + 'breakAfter', + 'breakBefore', + 'breakInside', + 'captionSide', + 'caret', + 'caretAnimation', + 'caretColor', + 'caretShape', + 'clear', + 'clip', + 'clipPath', + 'clipRule', + 'color', + 'colorInterpolationFilters', + 'colorScheme', + 'columnCount', + 'columnFill', + 'columnGap', + 'columnHeight', + 'columnRule', + 'columnRuleColor', + 'columnRuleStyle', + 'columnRuleWidth', + 'columnSpan', + 'columnWidth', + 'columnWrap', + 'columns', + 'contain', + 'containIntrinsicBlockSize', + 'containIntrinsicHeight', + 'containIntrinsicInlineSize', + 'containIntrinsicSize', + 'containIntrinsicWidth', + 'container', + 'containerName', + 'containerType', + 'content', + 'contentVisibility', + 'cornerBlockEndShape', + 'cornerBlockStartShape', + 'cornerBottomLeftShape', + 'cornerBottomRightShape', + 'cornerBottomShape', + 'cornerEndEndShape', + 'cornerEndStartShape', + 'cornerInlineEndShape', + 'cornerInlineStartShape', + 'cornerLeftShape', + 'cornerRightShape', + 'cornerShape', + 'cornerStartEndShape', + 'cornerStartStartShape', + 'cornerTopLeftShape', + 'cornerTopRightShape', + 'cornerTopShape', + 'counterIncrement', + 'counterReset', + 'counterSet', + 'cursor', + 'cx', + 'cy', + 'd', + 'direction', + 'display', + 'dominantBaseline', + 'dynamicRangeLimit', + 'emptyCells', + 'fieldSizing', + 'fill', + 'fillOpacity', + 'fillRule', + 'filter', + 'flex', + 'flexBasis', + 'flexDirection', + 'flexFlow', + 'flexGrow', + 'flexLineCount', + 'flexShrink', + 'flexWrap', + 'float', + 'floodColor', + 'floodOpacity', + 'font', + 'fontFamily', + 'fontFeatureSettings', + 'fontKerning', + 'fontLanguageOverride', + 'fontOpticalSizing', + 'fontPalette', + 'fontSize', + 'fontSizeAdjust', + 'fontSmooth', + 'fontStretch', + 'fontStyle', + 'fontSynthesis', + 'fontSynthesisPosition', + 'fontSynthesisSmallCaps', + 'fontSynthesisStyle', + 'fontSynthesisWeight', + 'fontVariant', + 'fontVariantAlternates', + 'fontVariantCaps', + 'fontVariantEastAsian', + 'fontVariantEmoji', + 'fontVariantLigatures', + 'fontVariantNumeric', + 'fontVariantPosition', + 'fontVariationSettings', + 'fontWeight', + 'fontWidth', + 'forcedColorAdjust', + 'frameSizing', + 'gap', + 'grid', + 'gridArea', + 'gridAutoColumns', + 'gridAutoFlow', + 'gridAutoRows', + 'gridColumn', + 'gridColumnEnd', + 'gridColumnGap', + 'gridColumnStart', + 'gridGap', + 'gridRow', + 'gridRowEnd', + 'gridRowGap', + 'gridRowStart', + 'gridTemplate', + 'gridTemplateAreas', + 'gridTemplateColumns', + 'gridTemplateRows', + 'hangingPunctuation', + 'height', + 'hyphenateCharacter', + 'hyphenateLimitChars', + 'hyphens', + 'imageOrientation', + 'imageRendering', + 'imageResolution', + 'imeMode', + 'initialLetter', + 'initialLetterAlign', + 'inlineSize', + 'inset', + 'insetBlock', + 'insetBlockEnd', + 'insetBlockStart', + 'insetInline', + 'insetInlineEnd', + 'insetInlineStart', + 'interactivity', + 'interestDelay', + 'interestDelayEnd', + 'interestDelayStart', + 'interpolateSize', + 'isolation', + 'justifyContent', + 'justifyItems', + 'justifySelf', + 'justifyTracks', + 'left', + 'letterSpacing', + 'lightingColor', + 'lineBreak', + 'lineClamp', + 'lineHeight', + 'lineHeightStep', + 'linkParameters', + 'listStyle', + 'listStyleImage', + 'listStylePosition', + 'listStyleType', + 'margin', + 'marginBlock', + 'marginBlockEnd', + 'marginBlockStart', + 'marginBottom', + 'marginInline', + 'marginInlineEnd', + 'marginInlineStart', + 'marginLeft', + 'marginRight', + 'marginTop', + 'marginTrim', + 'marker', + 'markerEnd', + 'markerMid', + 'markerStart', + 'mask', + 'maskBorder', + 'maskBorderMode', + 'maskBorderOutset', + 'maskBorderRepeat', + 'maskBorderSlice', + 'maskBorderSource', + 'maskBorderWidth', + 'maskClip', + 'maskComposite', + 'maskImage', + 'maskMode', + 'maskOrigin', + 'maskPosition', + 'maskRepeat', + 'maskSize', + 'maskType', + 'masonryAutoFlow', + 'mathDepth', + 'mathShift', + 'mathStyle', + 'maxBlockSize', + 'maxHeight', + 'maxInlineSize', + 'maxLines', + 'maxWidth', + 'minBlockSize', + 'minHeight', + 'minInlineSize', + 'minWidth', + 'mixBlendMode', + 'msAccelerator', + 'msBlockProgression', + 'msContentZoomChaining', + 'msContentZoomLimit', + 'msContentZoomLimitMax', + 'msContentZoomLimitMin', + 'msContentZoomSnap', + 'msContentZoomSnapPoints', + 'msContentZoomSnapType', + 'msContentZooming', + 'msFilter', + 'msFlowFrom', + 'msFlowInto', + 'msGridColumns', + 'msGridRows', + 'msHighContrastAdjust', + 'msHyphenateLimitChars', + 'msHyphenateLimitLines', + 'msHyphenateLimitZone', + 'msImeAlign', + 'msOverflowStyle', + 'msScrollChaining', + 'msScrollLimit', + 'msScrollLimitXMax', + 'msScrollLimitXMin', + 'msScrollLimitYMax', + 'msScrollLimitYMin', + 'msScrollRails', + 'msScrollSnapPointsX', + 'msScrollSnapPointsY', + 'msScrollSnapType', + 'msScrollSnapX', + 'msScrollSnapY', + 'msScrollTranslation', + 'msScrollbar3dlightColor', + 'msScrollbarArrowColor', + 'msScrollbarBaseColor', + 'msScrollbarDarkshadowColor', + 'msScrollbarFaceColor', + 'msScrollbarHighlightColor', + 'msScrollbarShadowColor', + 'msScrollbarTrackColor', + 'msTextAutospace', + 'msTouchSelect', + 'msUserSelect', + 'msWrapFlow', + 'msWrapMargin', + 'msWrapThrough', + 'objectFit', + 'objectPosition', + 'objectViewBox', + 'offset', + 'offsetAnchor', + 'offsetDistance', + 'offsetPath', + 'offsetPosition', + 'offsetRotate', + 'opacity', + 'order', + 'orphans', + 'outline', + 'outlineColor', + 'outlineOffset', + 'outlineStyle', + 'outlineWidth', + 'overflow', + 'overflowAnchor', + 'overflowBlock', + 'overflowClipBox', + 'overflowClipMargin', + 'overflowInline', + 'overflowWrap', + 'overflowX', + 'overflowY', + 'overlay', + 'overscrollBehavior', + 'overscrollBehaviorBlock', + 'overscrollBehaviorInline', + 'overscrollBehaviorX', + 'overscrollBehaviorY', + 'padding', + 'paddingBlock', + 'paddingBlockEnd', + 'paddingBlockStart', + 'paddingBottom', + 'paddingInline', + 'paddingInlineEnd', + 'paddingInlineStart', + 'paddingLeft', + 'paddingRight', + 'paddingTop', + 'page', + 'pageBreakAfter', + 'pageBreakBefore', + 'pageBreakInside', + 'paintOrder', + 'pathLength', + 'perspective', + 'perspectiveOrigin', + 'placeContent', + 'placeItems', + 'placeSelf', + 'pointerEvents', + 'position', + 'positionAnchor', + 'positionArea', + 'positionTry', + 'positionTryFallbacks', + 'positionTryOrder', + 'positionVisibility', + 'printColorAdjust', + 'quotes', + 'r', + 'readingFlow', + 'readingOrder', + 'resize', + 'right', + 'rotate', + 'rowGap', + 'rubyAlign', + 'rubyMerge', + 'rubyOverhang', + 'rubyPosition', + 'rx', + 'ry', + 'scale', + 'scrollAxisLock', + 'scrollBehavior', + 'scrollInitialTarget', + 'scrollMargin', + 'scrollMarginBlock', + 'scrollMarginBlockEnd', + 'scrollMarginBlockStart', + 'scrollMarginBottom', + 'scrollMarginInline', + 'scrollMarginInlineEnd', + 'scrollMarginInlineStart', + 'scrollMarginLeft', + 'scrollMarginRight', + 'scrollMarginTop', + 'scrollMarkerGroup', + 'scrollPadding', + 'scrollPaddingBlock', + 'scrollPaddingBlockEnd', + 'scrollPaddingBlockStart', + 'scrollPaddingBottom', + 'scrollPaddingInline', + 'scrollPaddingInlineEnd', + 'scrollPaddingInlineStart', + 'scrollPaddingLeft', + 'scrollPaddingRight', + 'scrollPaddingTop', + 'scrollSnapAlign', + 'scrollSnapStop', + 'scrollSnapType', + 'scrollTargetGroup', + 'scrollTimeline', + 'scrollTimelineAxis', + 'scrollTimelineName', + 'scrollbarColor', + 'scrollbarGutter', + 'scrollbarWidth', + 'shapeImageThreshold', + 'shapeMargin', + 'shapeOutside', + 'shapeRendering', + 'speakAs', + 'stopColor', + 'stopOpacity', + 'stroke', + 'strokeColor', + 'strokeDasharray', + 'strokeDashoffset', + 'strokeLinecap', + 'strokeLinejoin', + 'strokeMiterlimit', + 'strokeOpacity', + 'strokeWidth', + 'tabSize', + 'tableLayout', + 'textAlign', + 'textAlignLast', + 'textAnchor', + 'textAutospace', + 'textBox', + 'textBoxEdge', + 'textBoxTrim', + 'textCombineUpright', + 'textDecoration', + 'textDecorationColor', + 'textDecorationInset', + 'textDecorationLine', + 'textDecorationSkip', + 'textDecorationSkipInk', + 'textDecorationStyle', + 'textDecorationThickness', + 'textEmphasis', + 'textEmphasisColor', + 'textEmphasisPosition', + 'textEmphasisStyle', + 'textFit', + 'textIndent', + 'textJustify', + 'textOrientation', + 'textOverflow', + 'textRendering', + 'textShadow', + 'textSizeAdjust', + 'textSpacingTrim', + 'textTransform', + 'textUnderlineOffset', + 'textUnderlinePosition', + 'textWrap', + 'textWrapMode', + 'textWrapStyle', + 'timelineScope', + 'timelineTrigger', + 'timelineTriggerActivationRange', + 'timelineTriggerActivationRangeEnd', + 'timelineTriggerActivationRangeStart', + 'timelineTriggerActiveRange', + 'timelineTriggerActiveRangeEnd', + 'timelineTriggerActiveRangeStart', + 'timelineTriggerName', + 'timelineTriggerSource', + 'top', + 'touchAction', + 'transform', + 'transformBox', + 'transformOrigin', + 'transformStyle', + 'transition', + 'transitionBehavior', + 'transitionDelay', + 'transitionDuration', + 'transitionProperty', + 'transitionTimingFunction', + 'translate', + 'triggerScope', + 'unicodeBidi', + 'userSelect', + 'vectorEffect', + 'verticalAlign', + 'viewTimeline', + 'viewTimelineAxis', + 'viewTimelineInset', + 'viewTimelineName', + 'viewTransitionClass', + 'viewTransitionName', + 'viewTransitionScope', + 'visibility', + 'whiteSpace', + 'whiteSpaceCollapse', + 'widows', + 'width', + 'willChange', + 'windowDrag', + 'wordBreak', + 'wordSpacing', + 'wordWrap', + 'writingMode', + 'x', + 'y', + 'zIndex', + 'zoom', +] as const + +export const BUILTIN_SHORTHAND_TARGETS = { + bg: ['background'], + bgAttachment: ['background-attachment'], + bgClip: ['background-clip'], + bgColor: ['background-color'], + bgImage: ['background-image'], + bgImg: ['background-image'], + bgOrigin: ['background-origin'], + bgPosition: ['background-position'], + bgPositionX: ['background-position-x'], + bgPositionY: ['background-position-y'], + bgPos: ['background-position'], + bgPosX: ['background-position-x'], + bgPosY: ['background-position-y'], + bgRepeat: ['background-repeat'], + bgSize: ['background-size'], + bgBlendMode: ['background-blend-mode'], + backgroundImg: ['background-image'], + animationDir: ['animation-direction'], + flexDir: ['flex-direction'], + pos: ['position'], + m: ['margin'], + mt: ['margin-top'], + mr: ['margin-right'], + mb: ['margin-bottom'], + ml: ['margin-left'], + p: ['padding'], + pt: ['padding-top'], + pr: ['padding-right'], + pb: ['padding-bottom'], + pl: ['padding-left'], + w: ['width'], + h: ['height'], + minW: ['min-width'], + minH: ['min-height'], + maxW: ['max-width'], + maxH: ['max-height'], + mx: ['margin-left', 'margin-right'], + my: ['margin-top', 'margin-bottom'], + px: ['padding-left', 'padding-right'], + py: ['padding-top', 'padding-bottom'], + boxSize: ['width', 'height'], + borderBottomRadius: [ + 'border-bottom-left-radius', + 'border-bottom-right-radius', + ], + borderTopRadius: ['border-top-left-radius', 'border-top-right-radius'], + borderLeftRadius: ['border-top-left-radius', 'border-bottom-left-radius'], + borderRightRadius: ['border-top-right-radius', 'border-bottom-right-radius'], + objectPos: ['object-position'], + offsetPos: ['offset-position'], + maskPos: ['mask-position'], + maskImg: ['mask-image'], +} as const diff --git a/packages/plugin-utils/src/shorthands.ts b/packages/plugin-utils/src/shorthands.ts new file mode 100644 index 000000000..14446301e --- /dev/null +++ b/packages/plugin-utils/src/shorthands.ts @@ -0,0 +1,121 @@ +import { BUILTIN_SHORTHAND_TARGETS, CSS_PROPERTY_NAMES } from './property-names' + +type KebabTail = S extends `${infer Head}${infer Tail}` + ? `${Head extends Lowercase ? Head : `-${Lowercase}`}${KebabTail}` + : S +type Kebab = + KebabTail extends `-${infer Rest}` ? Rest : KebabTail +type CssName = (typeof CSS_PROPERTY_NAMES)[number] +type CssKebabName = S extends + `Webkit${string}` | `Moz${string}` | `ms${string}` | `O${string}` + ? `-${Kebab}` + : Kebab + +export type CustomShorthandTarget = + | CssName + | CssKebabName + | keyof typeof BUILTIN_SHORTHAND_TARGETS + | `--${string}` + +export interface ShorthandErrorLocation { + readonly alias: string | null + readonly index: number | null + readonly target: unknown +} + +export class ShorthandConfigError extends Error { + readonly alias: string | null + readonly index: number | null + readonly target: unknown + readonly path: string + + constructor(location: ShorthandErrorLocation, requirement: string) { + const { alias, index, target } = location + const path = `shorthands${alias === null ? '' : `[${JSON.stringify(alias)}]`}${index === null ? '' : `[${index}]`}` + super( + `devup-ui option ${path}: shorthand ${JSON.stringify(alias)} cannot use ${typeof target === 'string' ? JSON.stringify(target) : typeof target} at build time: ${requirement}`, + ) + this.name = 'ShorthandConfigError' + this.alias = alias + this.index = index + this.target = target + this.path = path + } +} + +function kebabName(name: string): string { + const prefix = /^(Webkit|Moz|ms|O)[A-Z]/.test(name) ? '-' : '' + return ( + prefix + + name.replace( + /[A-Z]/g, + (letter, index: number) => + `${index === 0 ? '' : '-'}${letter.toLowerCase()}`, + ) + ) +} + +const TARGETS = new Map( + CSS_PROPERTY_NAMES.flatMap((name) => { + const canonical = kebabName(name) + return [ + [name, [canonical]], + [canonical, [canonical]], + ] satisfies [string, readonly string[]][] + }), +) +for (const [alias, targets] of Object.entries(BUILTIN_SHORTHAND_TARGETS)) { + TARGETS.set(alias, targets) +} + +export function isCustomShorthandTarget( + target: unknown, +): target is CustomShorthandTarget { + return ( + typeof target === 'string' && + (TARGETS.has(target) || + /^--[A-Za-z0-9_\-\u0080-\u{10FFFF}]+$/u.test(target)) + ) +} + +function isRecord(input: unknown): input is Record { + return typeof input === 'object' && input !== null && !Array.isArray(input) +} + +function isTargets(targets: unknown): targets is readonly unknown[] { + return Array.isArray(targets) +} + +export function normalizeShorthands( + input: unknown, +): Record { + if (!isRecord(input)) { + throw new ShorthandConfigError( + { alias: null, index: null, target: input }, + 'shorthands must be an object', + ) + } + const entries: [string, readonly string[]][] = [] + for (const [alias, targets] of Object.entries(input).sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0, + )) { + if (!isTargets(targets)) { + throw new ShorthandConfigError( + { alias, index: null, target: targets }, + 'its targets must be an array', + ) + } + const normalized: string[] = [] + for (const [index, target] of targets.entries()) { + if (!isCustomShorthandTarget(target)) { + throw new ShorthandConfigError( + { alias, index, target }, + 'its targets must be supported CSS properties, built-in aliases or custom properties', + ) + } + normalized.push(...(TARGETS.get(target) ?? [target])) + } + entries.push([alias, normalized]) + } + return Object.fromEntries(entries) +} diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index e92f87f1b..be51ebf84 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -1,3 +1,5 @@ +import type { CustomShorthandTarget } from './shorthands' + /** * Typography definition for a single breakpoint or non-responsive typography */ @@ -64,7 +66,7 @@ export interface DevupTheme { } /** Custom style prop aliases mapped to one or more CSS property names. */ -export type CustomShorthands = Record +export type CustomShorthands = Record /** * Devup configuration file structure (devup.json) From 893173479716a9cd45b83afdb6ffafffe9c5b146 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 01:36:31 +0900 Subject: [PATCH 5/9] fix(plugins): validate shorthand options before plugin setup Refs #684 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../bun-plugin/src/__tests__/plugin.test.ts | 41 ++++++++++++ packages/bun-plugin/src/plugin.ts | 8 ++- .../next-plugin/src/__tests__/plugin.test.ts | 64 ++++++++++++++++++- packages/next-plugin/src/plugin.ts | 7 +- .../src/__tests__/plugin.test.ts | 36 +++++++++++ packages/rsbuild-plugin/src/plugin.ts | 3 +- .../vite-plugin/src/__tests__/plugin.test.ts | 36 +++++++++++ packages/vite-plugin/src/plugin.ts | 3 +- .../src/__tests__/plugin.test.ts | 38 +++++++++++ packages/webpack-plugin/src/plugin.ts | 3 +- 10 files changed, 228 insertions(+), 11 deletions(-) diff --git a/packages/bun-plugin/src/__tests__/plugin.test.ts b/packages/bun-plugin/src/__tests__/plugin.test.ts index e93d67418..d560b794c 100644 --- a/packages/bun-plugin/src/__tests__/plugin.test.ts +++ b/packages/bun-plugin/src/__tests__/plugin.test.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs' import * as fsPromises from 'node:fs/promises' import { join } from 'node:path' +import { ShorthandConfigError } from '@devup-ui/plugin-utils' import * as wasm from '@devup-ui/wasm' import { afterEach, @@ -13,6 +14,46 @@ import { spyOn, } from 'bun:test' +import { register } from '../plugin' + +describe('shorthand creation boundary', () => { + it('registers canonical targets when the plugin is registered', async () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + await register({ + shorthands: { bgx: ['backgroundColor', 'py', '--Gap'] }, + }) + expect(registration.mock.calls).toEqual([ + [ + { + bgx: ['background-color', 'padding-top', 'padding-bottom', '--Gap'], + }, + ], + ]) + } finally { + registration.mockRestore() + } + }) + + it('rejects a typo before registration when runtime options bypass types', () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + expect(() => + Reflect.apply(register, undefined, [ + { shorthands: { bgx: ['backgroundColour'] } }, + ]), + ).toThrow(ShorthandConfigError) + expect(registration).not.toHaveBeenCalled() + } finally { + registration.mockRestore() + } + }) +}) + let getDefaultThemeSpy: ReturnType let existsSyncSpy: ReturnType let readFileSpy: ReturnType diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 81edd30ae..53ba57c8a 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -9,6 +9,7 @@ import { type CustomShorthands, loadDevupConfig, mergeImportAliases, + normalizeShorthands, readJsxImportSource, } from '@devup-ui/plugin-utils' import { @@ -59,8 +60,8 @@ async function writeDataFiles() { await writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8') } -async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { - registerShorthands(shorthands ?? {}) +async function initialize(shorthands: Record) { + registerShorthands(shorthands) setModuleResolver(createModuleResolver()) if (!existsSync(distDir)) await mkdir(distDir, { recursive: true }) await writeFile(join(distDir, '.gitignore'), '*', 'utf-8') @@ -118,11 +119,12 @@ async function loadSourceFile(filePath: string) { // against the @devup-ui/react runtime stubs (throwing "Cannot run on the // runtime"). function register(options: DevupUIBunPluginOptions = {}) { + const shorthands = normalizeShorthands(options.shorthands ?? {}) return plugin({ name: 'devup-ui', async setup(build) { - await initialize(options) + await initialize(shorthands) setDebug(true) // Resolve devup-ui CSS files onto a path-free virtual id, so nothing diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 0f4ddce2d..0c5dd0464 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -187,6 +187,60 @@ afterEach(() => { }) describe('DevupUINextPlugin', () => { + it.each(['1', 'auto', undefined])( + 'rejects typos at creation before setup in mode %s', + (mode) => { + if (mode === undefined) delete process.env.TURBOPACK + else process.env.TURBOPACK = mode + expect(() => + Reflect.apply(DevupUI, undefined, [ + {}, + { shorthands: { bgx: ['backgroundColour'] } }, + ]), + ).toThrow(importGraphModule.ShorthandConfigError) + expect(writeFileSyncSpy).not.toHaveBeenCalled() + expect(startCoordinatorSpy).not.toHaveBeenCalled() + expect(devupUIWebpackPluginSpy).not.toHaveBeenCalled() + }, + ) + + it('registers canonical targets when a Turbopack plugin is created', () => { + process.env.TURBOPACK = '1' + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + DevupUI({}, { shorthands: { bgx: ['backgroundColor', 'py', '--Gap'] } }) + expect(registration.mock.calls).toEqual([ + [ + { + bgx: ['background-color', 'padding-top', 'padding-bottom', '--Gap'], + }, + ], + ]) + } finally { + registration.mockRestore() + } + }) + + it('rejects a typo before reading an available production handoff', () => { + process.env.TURBOPACK = '1' + setNodeEnv('production') + DevupUI({}, { shorthands: { bgx: ['backgroundColor'] } }) + reloadTurboSetupModuleForTesting() + readFileSyncSpy.mockClear() + const shorthands = { bgx: ['backgroundColour'] } + Object.defineProperty(shorthands, 'toJSON', { + value: () => ({ bgx: ['background-color'] }), + }) + + expect(() => + Reflect.apply(DevupUI, undefined, [{}, { shorthands }]), + ).toThrow(importGraphModule.ShorthandConfigError) + expect(readFileSyncSpy).not.toHaveBeenCalled() + expect(startCoordinatorSpy).toHaveBeenCalledTimes(1) + }) + describe('webpack', () => { it('should apply webpack plugin', async () => { const ret = DevupUI({}) @@ -261,6 +315,7 @@ describe('DevupUINextPlugin', () => { 'computeCompiledFiles', ).mockReturnValue(['src/app/page.tsx']) const profileSpy = spyOn(console, 'info').mockImplementation(() => {}) + const normalizeSpy = spyOn(importGraphModule, 'normalizeShorthands') const handoffFile = join('df', 'setup.bin') let handoff: Parameters[1] | undefined writeFileSyncSpy.mockImplementation((path, data) => { @@ -277,11 +332,14 @@ describe('DevupUINextPlugin', () => { }) try { - const first = DevupUI({}, { singleCss: true }) + const first = DevupUI( + {}, + { singleCss: true, shorthands: { bgx: ['backgroundColor'] } }, + ) reloadTurboSetupModuleForTesting() const second = DevupUI( { env: { EXISTING: 'value' } }, - { singleCss: true }, + { singleCss: true, shorthands: { bgx: ['background-color'] } }, ) expect(second.turbopack?.rules).toEqual(first.turbopack?.rules) @@ -292,11 +350,13 @@ describe('DevupUINextPlugin', () => { expect(graphSpy).toHaveBeenCalledTimes(1) expect(codeExtractWithoutSourceMapSpy).toHaveBeenCalledTimes(1) expect(startCoordinatorSpy).toHaveBeenCalledTimes(1) + expect(normalizeSpy).toHaveBeenCalledTimes(2) expect(unlinkSyncSpy).toHaveBeenCalledWith(handoffFile) expect(profileSpy).toHaveBeenCalledWith( expect.stringContaining('"cacheHit":true'), ) } finally { + normalizeSpy.mockRestore() profileSpy.mockRestore() compiledSpy.mockRestore() graphSpy.mockRestore() diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index e69c4d21f..6641e0d78 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -19,6 +19,7 @@ import { type DevupUIBasePluginOptions, loadDevupConfigSync, mergeImportAliases, + normalizeShorthands, planAtomHoist, readJsxImportSource, type StaticImportGraph, @@ -124,6 +125,7 @@ export function DevupUI( config: NextConfig, options: DevupUINextPluginOptions = {}, ): NextConfig { + const normalizedShorthands = normalizeShorthands(options.shorthands ?? {}) const pluginStartedAt = profileStart() const isTurbo = process.env.TURBOPACK === '1' || process.env.TURBOPACK === 'auto' @@ -140,7 +142,6 @@ export function DevupUI( devupFile = 'devup.json', include = [], prefix, - shorthands, atomHoist, importAliases: userImportAliases, } = options @@ -174,7 +175,7 @@ export function DevupUI( include, libPackage, prefix, - shorthands, + shorthands: normalizedShorthands, singleCss, sourceMap, }) @@ -231,7 +232,7 @@ export function DevupUI( setPrefix, } = wasm - registerShorthands(shorthands ?? {}) + registerShorthands(normalizedShorthands) if (prefix) { setPrefix(prefix) diff --git a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts index 3fa5b9fd4..b59f6bde1 100644 --- a/packages/rsbuild-plugin/src/__tests__/plugin.test.ts +++ b/packages/rsbuild-plugin/src/__tests__/plugin.test.ts @@ -17,6 +17,42 @@ import { import { DevupUI } from '../plugin' +describe('shorthand creation boundary', () => { + it('registers canonical targets when the plugin is created', () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + DevupUI({ shorthands: { bgx: ['backgroundColor', 'py', '--Gap'] } }) + expect(registration.mock.calls).toEqual([ + [ + { + bgx: ['background-color', 'padding-top', 'padding-bottom', '--Gap'], + }, + ], + ]) + } finally { + registration.mockRestore() + } + }) + + it('rejects a typo before registration when runtime options bypass types', () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + expect(() => + Reflect.apply(DevupUI, undefined, [ + { shorthands: { bgx: ['backgroundColour'] } }, + ]), + ).toThrow(pluginUtils.ShorthandConfigError) + expect(registration).not.toHaveBeenCalled() + } finally { + registration.mockRestore() + } + }) +}) + type CodeExtractResult = ReturnType type RsbuildPlugin = ReturnType type RsbuildSetupContext = Parameters[0] diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 1a796c057..f6b12810d 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -14,6 +14,7 @@ import { type ImportAliases, loadDevupConfig, mergeImportAliases, + normalizeShorthands, planAtomHoist, readJsxImportSource, } from '@devup-ui/plugin-utils' @@ -118,7 +119,7 @@ export const DevupUI = ({ atomHoist, importAliases: userImportAliases, }: Partial = {}): RsbuildPlugin => { - registerShorthands(shorthands ?? {}) + registerShorthands(normalizeShorthands(shorthands ?? {})) const importAliases = mergeImportAliases( userImportAliases, readJsxImportSource(), diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 2c910f406..a36848cfa 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -16,6 +16,42 @@ import { import { DevupUI } from '../plugin' +describe('shorthand creation boundary', () => { + it('registers canonical targets when the plugin is created', () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + DevupUI({ shorthands: { bgx: ['backgroundColor', 'py', '--Gap'] } }) + expect(registration.mock.calls).toEqual([ + [ + { + bgx: ['background-color', 'padding-top', 'padding-bottom', '--Gap'], + }, + ], + ]) + } finally { + registration.mockRestore() + } + }) + + it('rejects a typo before registration when runtime options bypass types', () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + expect(() => + Reflect.apply(DevupUI, undefined, [ + { shorthands: { bgx: ['backgroundColour'] } }, + ]), + ).toThrow(pluginUtils.ShorthandConfigError) + expect(registration).not.toHaveBeenCalled() + } finally { + registration.mockRestore() + } + }) +}) + type CodeExtractResult = ReturnType interface ConfigHookMeta { viteVersion?: string diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 2ea19c45e..7c6b3b508 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -15,6 +15,7 @@ import { listSourceFiles, loadDevupConfig, mergeImportAliases, + normalizeShorthands, planAtomHoist, readJsxImportSource, } from '@devup-ui/plugin-utils' @@ -270,7 +271,7 @@ export function DevupUI({ atomHoist, importAliases: userImportAliases, }: Partial = {}): PluginOption { - registerShorthands(shorthands ?? {}) + registerShorthands(normalizeShorthands(shorthands ?? {})) setDebug(debug) if (prefix) { setPrefix(prefix) diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index 2692ead30..e88e62ace 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -18,6 +18,44 @@ import type { Compiler } from 'webpack' import { DevupUIWebpackPlugin } from '../plugin' +describe('shorthand creation boundary', () => { + it('registers canonical targets when the plugin is constructed', () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + new DevupUIWebpackPlugin({ + shorthands: { bgx: ['backgroundColor', 'py', '--Gap'] }, + }) + expect(registration.mock.calls).toEqual([ + [ + { + bgx: ['background-color', 'padding-top', 'padding-bottom', '--Gap'], + }, + ], + ]) + } finally { + registration.mockRestore() + } + }) + + it('rejects a typo before registration when runtime options bypass types', () => { + const registration = spyOn(wasm, 'registerShorthands').mockReturnValue( + undefined, + ) + try { + expect(() => + Reflect.construct(DevupUIWebpackPlugin, [ + { shorthands: { bgx: ['backgroundColour'] } }, + ]), + ).toThrow(pluginUtils.ShorthandConfigError) + expect(registration).not.toHaveBeenCalled() + } finally { + registration.mockRestore() + } + }) +}) + type CodeExtractResult = ReturnType interface MockCompiler { options: { diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 7b0cb1f45..9d77cafeb 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -15,6 +15,7 @@ import { listSourceFiles, loadDevupConfigSync, mergeImportAliases, + normalizeShorthands, planAtomHoist, readJsxImportSource, type WasmImportAliases, @@ -95,7 +96,7 @@ export class DevupUIWebpackPlugin { atomHoist, importAliases: userImportAliases, }: Partial = {}) { - registerShorthands(shorthands ?? {}) + registerShorthands(normalizeShorthands(shorthands ?? {})) this.importAliases = mergeImportAliases( userImportAliases, readJsxImportSource(), From d69f519392346c5b32fccf7af2fd4911b698d924 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Tue, 6 Oct 2026 02:38:34 +0900 Subject: [PATCH 6/9] fix(extractor): validate order metadata in composed css props Refs #684 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_style_order_contract.json | 2 +- libs/extractor/src/import_alias_visit.rs | 3 + libs/extractor/src/lib.rs | 2 + .../src/style_order_css_prop_tests.rs | 208 ++++++++++++++++++ libs/extractor/src/style_order_validation.rs | 21 ++ libs/extractor/src/visit.rs | 5 + 6 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 libs/extractor/src/style_order_css_prop_tests.rs diff --git a/.changepacks/changepack_log_style_order_contract.json b/.changepacks/changepack_log_style_order_contract.json index b1ea234cd..cee54744f 100644 --- a/.changepacks/changepack_log_style_order_contract.json +++ b/.changepacks/changepack_log_style_order_contract.json @@ -3,6 +3,6 @@ "bindings/devup-ui-wasm/package.json": "Patch", "packages/eslint-plugin/package.json": "Patch" }, - "note": "Validate explicit static styleOrder as an integer from 1 to 254 or its canonical decimal digit string, consistently in extraction and lint. Invalid static values and conditional branches now produce located build errors instead of silently changing cascade order.", + "note": "Validate explicit static styleOrder as an integer from 1 to 254 or its canonical decimal digit string, consistently in extraction and lint, including composed Emotion css props. Keep order metadata out of library px conversion. Invalid static values and conditional branches now produce located build errors instead of silently changing cascade order.", "date": "2026-10-01T00:00:00.000Z" } diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index db6e569c8..3e5ffe940 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -169,6 +169,9 @@ impl LibraryNumbers<'_> { } fn pixelify_value(&mut self, key: &str, value: &Expression) { + if key == "styleOrder" { + return; + } if let Some(number) = js_number_literal(value) { if number != 0.0 && !keeps_bare_number(key) { let span = value.span(); diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 704e77834..22bd75861 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -19,6 +19,8 @@ mod shorthand_tests; mod source_map; mod style_order; #[cfg(test)] +mod style_order_css_prop_tests; +#[cfg(test)] mod style_order_tests; mod style_order_validation; mod style_values; diff --git a/libs/extractor/src/style_order_css_prop_tests.rs b/libs/extractor/src/style_order_css_prop_tests.rs new file mode 100644 index 000000000..aeb5c4e10 --- /dev/null +++ b/libs/extractor/src/style_order_css_prop_tests.rs @@ -0,0 +1,208 @@ +use std::{collections::HashMap, error::Error}; + +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +use crate::{ + ExtractOption, ImportAlias, extract, extract_style::extract_style_value::ExtractStyleValue, +}; + +const FIXTURES: &str = include_str!("../../../test-fixtures/style-order.json"); + +fn emotion_option() -> ExtractOption { + ExtractOption { + import_aliases: HashMap::from([ + ("@emotion/react".to_string(), ImportAlias::NamedToNamed), + ( + "@emotion/react/jsx-runtime".to_string(), + ImportAlias::NamedToNamed, + ), + ]), + ..ExtractOption::default() + } +} + +fn source_for(runtime: bool, value: &str) -> String { + if runtime { + format!( + "import {{css}} from '@emotion/react';\nimport {{jsx as render}} from '@emotion/react/jsx-runtime';\nexport const App=()=> render('div', {{css: {value}}});" + ) + } else { + format!( + "import {{css}} from '@emotion/react';\nexport const App=()=>
;" + ) + } +} + +fn location(source: &str, leaf: &str) -> Result> { + let offset = source.rfind(leaf).ok_or("missing offending value")?; + let before = source.get(..offset).ok_or("invalid fixture offset")?; + let line = before.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = before + .rsplit('\n') + .next() + .ok_or("missing line")? + .chars() + .count() + + 1; + Ok(format!("css-order.tsx:{line}:{column}:")) +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn css_prop_rejects_original_quoted_value_when_css_call_is_consumed( + #[case] runtime: bool, +) -> Result<(), Box> { + // Given + let source = if runtime { + source_for(true, "css({color:'red',styleOrder:'100px'})") + } else { + "import {css} from '@emotion/react'; export const App=()=>
".to_string() + }; + reset_class_map(); + reset_file_map(); + let expected = location(&source, "'100px'")?; + // When + let error = extract("css-order.tsx", &source, emotion_option()) + .err() + .ok_or("malformed order unexpectedly compiled")? + .to_string(); + // Then + assert!( + error.lines().any(|line| line.starts_with(&expected)), + "{error}" + ); + assert!(error.contains("`'100px'`"), "{error}"); + Ok(()) +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn css_prop_reports_original_branch_when_nested_composition_is_consumed( + #[case] runtime: bool, +) -> Result<(), Box> { + // Given + let source = source_for( + runtime, + "css({margin:0}, [css({color:'red',styleOrder:on ? 2 : '\\u0030\\u0031'})])", + ); + reset_class_map(); + reset_file_map(); + let expected = location(&source, "'\\u0030\\u0031'")?; + // When + let error = extract("css-order.tsx", &source, emotion_option()) + .err() + .ok_or("malformed branch unexpectedly compiled")? + .to_string(); + // Then + assert!( + error.lines().any(|line| line.starts_with(&expected)), + "{error}" + ); + assert!(error.contains("`'\\u0030\\u0031'`"), "{error}"); + Ok(()) +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn css_prop_follows_shared_fixtures_when_rules_are_consumed( + #[case] runtime: bool, + #[values( + "RULES", + "css(RULES)", + "css({margin:0}, [css(RULES)])", + "[{margin:0}, [css(RULES)]]" + )] + shape: &str, +) -> Result<(), Box> { + let fixtures: serde_json::Value = serde_json::from_str(FIXTURES)?; + for fixture in fixtures.as_array().ok_or("expected fixture array")? { + // Given + let expression = fixture["expression"].as_str().ok_or("missing expression")?; + let valid = fixture["valid"].as_bool().ok_or("missing validity")?; + let rules = format!("{{color:'red',styleOrder:{expression}}}"); + let source = source_for(runtime, &shape.replace("RULES", &rules)); + reset_class_map(); + reset_file_map(); + // When + let result = extract("css-order.tsx", &source, emotion_option()); + // Then + if valid { + result.map_err(|error| format!("{source}\n{error}"))?; + } else { + let error = result + .err() + .ok_or_else(|| format!("invalid order compiled: {source}"))? + .to_string(); + let leaf = expression + .rsplit_once(" : ") + .or_else(|| expression.rsplit_once(" && ")) + .map_or(expression, |(_, leaf)| leaf); + let expected = location(&source, leaf)?; + assert!( + error.lines().any(|line| line.starts_with(&expected)), + "{source}\n{error}" + ); + assert!(error.contains("`styleOrder()` cannot use"), "{error}"); + } + } + Ok(()) +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn css_prop_selects_expected_layer_when_static_order_is_valid( + #[case] runtime: bool, + #[values( + "RULES", + "css(RULES)", + "css({margin:0}, [css(RULES)])", + "[{margin:0}, [css(RULES)]]" + )] + shape: &str, +) -> Result<(), Box> { + for (expression, expected) in [ + ("1", 1), + ("254", 254), + ("100.0", 100), + ("1e2", 100), + ("'100'", 100), + ("`100`", 100), + ("+'01'", 1), + ("+'0x10'", 16), + ("+'1e2'", 100), + ("+'1.0'", 1), + ("+true", 1), + ("-'-1'", 1), + ] { + // Given + let rules = format!("{{color:'red',styleOrder:{expression}}}"); + let source = source_for(runtime, &shape.replace("RULES", &rules)); + reset_class_map(); + reset_file_map(); + // When + let output = extract("css-order.tsx", &source, emotion_option())?; + // Then + let orders: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.property == "color" => { + Some(style.style_order) + } + _ => None, + }) + .collect(); + assert_eq!(orders, vec![Some(expected)], "{source}"); + } + Ok(()) +} diff --git a/libs/extractor/src/style_order_validation.rs b/libs/extractor/src/style_order_validation.rs index 20232300b..d080bef8a 100644 --- a/libs/extractor/src/style_order_validation.rs +++ b/libs/extractor/src/style_order_validation.rs @@ -178,6 +178,27 @@ mod tests { use oxc_parser::Parser; use oxc_span::SourceType; + #[test] + fn style_order_runtime_reads_are_not_mistaken_for_static_values() + -> Result<(), Box> { + let allocator = Allocator::default(); + let bindings = StyleValues::default(); + for source in ["state.order", "readOrder()"] { + let value = Parser::new(&allocator, source, SourceType::ts()) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let mut errors = Vec::new(); + Validation { + bindings: &bindings, + errors: &mut errors, + } + .value(&value); + assert_eq!(errors, vec![], "{source}"); + assert_eq!(crate::style_order::static_order(&value), None, "{source}"); + } + Ok(()) + } + #[test] fn style_order_generated_literals_use_readable_fallbacks() { let allocator = Allocator::default(); diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 2888aa311..aaa8962ee 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -683,6 +683,11 @@ impl<'a> DevupVisitor<'a> { props.extend(values.into_iter().map(ExtractStyleProp::Static)); } KnownStyles::Rules(mut rules) => { + crate::style_order_validation::validate_rules( + &rules, + &self.style_values, + &mut self.errors, + ); self.style_values.read_in(&self.ast, &mut rules); let ExtractResult { mut styles, From 4825e4beeeea0cb8cb604b2d83c7c512b7c9ce21 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 8 Oct 2026 23:00:13 +0900 Subject: [PATCH 7/9] fix(packages): restore main release baseline in types and lint stack Restore the released package versions from main 11790bef/#700 and remove three already-consumed changepacks accidentally retained when ours merge4473674c marked earlier main history integrated without its contents. Retain every StyleX/shorthand/lint feature and plugin-utils type-test/build commands, regenerating only the nine workspace version entries with bun install. This coherent release-baseline restoration prevents publishing older versions or replaying already-released changes; fresh WASM/root builds and exact manifest/feature assertions pass. Refs #684 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_global_css_fixes.json | 7 ------- .../changepack_log_lint_style_positions.json | 7 ------- .changepacks/changepack_log_theme_output.json | 8 -------- bindings/devup-ui-wasm/package.json | 2 +- bun.lock | 18 +++++++++--------- packages/bun-plugin/package.json | 2 +- packages/components/package.json | 2 +- packages/eslint-plugin/package.json | 2 +- packages/next-plugin/package.json | 2 +- packages/plugin-utils/package.json | 2 +- packages/rsbuild-plugin/package.json | 2 +- packages/vite-plugin/package.json | 2 +- packages/webpack-plugin/package.json | 2 +- 13 files changed, 18 insertions(+), 40 deletions(-) delete mode 100644 .changepacks/changepack_log_global_css_fixes.json delete mode 100644 .changepacks/changepack_log_lint_style_positions.json delete mode 100644 .changepacks/changepack_log_theme_output.json diff --git a/.changepacks/changepack_log_global_css_fixes.json b/.changepacks/changepack_log_global_css_fixes.json deleted file mode 100644 index 6f7ea5cc9..000000000 --- a/.changepacks/changepack_log_global_css_fixes.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "changes": { - "bindings/devup-ui-wasm/package.json": "Patch" - }, - "note": "globalCss: theme tokens ($text) resolve to var(--text) in global rules instead of being written as invalid $text; imports given as { url, query } objects with unquoted keys are emitted instead of dropped; an '@layer name' key puts the rules it holds in that cascade layer (layers nest as base.reset) instead of flattening them into invalid declarations, and a layer name that is not one identifier is a build error", - "date": "2026-09-30T00:00:00.000Z" -} diff --git a/.changepacks/changepack_log_lint_style_positions.json b/.changepacks/changepack_log_lint_style_positions.json deleted file mode 100644 index e26d12df9..000000000 --- a/.changepacks/changepack_log_lint_style_positions.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "changes": { - "packages/eslint-plugin/package.json": "Patch" - }, - "note": "no-duplicate-value, no-useless-responsive, no-useless-tailing-nulls, no-typography-token-prefix and prefer-media-shorthand only report and fix values the build reads as styles: style props of Box, Flex and the other style components and the arguments of css, globalCss and keyframes, through style objects, responsive arrays, conditions and spreads. Arrays and keys in props the component passes through (data-*, aria-*, event handlers, HTML attributes, props, styleVars), in arguments of other functions and under imports/fontFaces/params are left alone, where autofix used to rewrite them; styles of a component nested in another's prop are checked too. css-utils-literal-only reads a css() or keyframes() result held in a const of any scope as static, as the build does", - "date": "2026-09-30T00:00:00.000Z" -} diff --git a/.changepacks/changepack_log_theme_output.json b/.changepacks/changepack_log_theme_output.json deleted file mode 100644 index 3f2552ce4..000000000 --- a/.changepacks/changepack_log_theme_output.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "changes": { - "bindings/devup-ui-wasm/package.json": "Patch", - "packages/plugin-utils/package.json": "Patch" - }, - "note": "Theme CSS: a color only one variant defines is defined in that variant; a variant renders dark only when it is named dark or listed as dark in the new theme.colorScheme, others light, and light-dark() only encodes a light default with one dark variant; theme names that are not CSS identifiers are quoted in [data-theme]; typography emits fontStyle and textTransform; color variables come out in a fixed order. $tokens with dashes (text-primary) resolve instead of breaking into var(--text)-primary, and dotted length and shadow names are declared with dashes like their references. registerTheme reports, with the theme path, a token name the $token syntax cannot reach and two tokens of a variant that become the same CSS variable (a-b and a.b)", - "date": "2026-09-30T00:00:00.000Z" -} diff --git a/bindings/devup-ui-wasm/package.json b/bindings/devup-ui-wasm/package.json index d8f232197..c30850d49 100644 --- a/bindings/devup-ui-wasm/package.json +++ b/bindings/devup-ui-wasm/package.json @@ -17,7 +17,7 @@ "react", "wasm" ], - "version": "1.0.82", + "version": "1.0.83", "scripts": { "build": "wasm-pack build --target nodejs --out-dir ./pkg --out-name index && node script.js", "test": "wasm-pack test --node" diff --git a/bun.lock b/bun.lock index 14197de29..298725c65 100644 --- a/bun.lock +++ b/bun.lock @@ -469,11 +469,11 @@ }, "bindings/devup-ui-wasm": { "name": "@devup-ui/wasm", - "version": "1.0.82", + "version": "1.0.83", }, "packages/bun-plugin": { "name": "@devup-ui/bun-plugin", - "version": "1.0.21", + "version": "1.0.22", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -488,7 +488,7 @@ }, "packages/components": { "name": "@devup-ui/components", - "version": "0.1.59", + "version": "0.1.60", "dependencies": { "@devup-ui/react": "workspace:^", "clsx": "^2.1", @@ -515,7 +515,7 @@ }, "packages/eslint-plugin": { "name": "@devup-ui/eslint-plugin", - "version": "1.0.21", + "version": "1.0.22", "dependencies": { "@typescript-eslint/utils": "^8.70", "typescript-eslint": "^8.70", @@ -532,7 +532,7 @@ }, "packages/next-plugin": { "name": "@devup-ui/next-plugin", - "version": "1.0.89", + "version": "1.0.90", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -550,7 +550,7 @@ }, "packages/plugin-utils": { "name": "@devup-ui/plugin-utils", - "version": "1.0.16", + "version": "1.0.17", "devDependencies": { "typescript": "^7.0.2", }, @@ -591,7 +591,7 @@ }, "packages/rsbuild-plugin": { "name": "@devup-ui/rsbuild-plugin", - "version": "1.0.66", + "version": "1.0.67", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -607,7 +607,7 @@ }, "packages/vite-plugin": { "name": "@devup-ui/vite-plugin", - "version": "1.0.72", + "version": "1.0.73", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -622,7 +622,7 @@ }, "packages/webpack-plugin": { "name": "@devup-ui/webpack-plugin", - "version": "1.0.70", + "version": "1.0.71", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", diff --git a/packages/bun-plugin/package.json b/packages/bun-plugin/package.json index 01dd0c9f7..0542861cf 100644 --- a/packages/bun-plugin/package.json +++ b/packages/bun-plugin/package.json @@ -18,7 +18,7 @@ "bun" ], "type": "module", - "version": "1.0.21", + "version": "1.0.22", "scripts": { "lint": "eslint", "build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.cjs.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external && bun build --target node src/register.ts --production --env=disable --outfile dist/register.cjs --format cjs --packages external && bun build --target node src/register.ts --production --env=disable --outfile dist/register.mjs --format esm --packages external", diff --git a/packages/components/package.json b/packages/components/package.json index 25e552f51..7572a3c1e 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -16,7 +16,7 @@ "css-in-js-framework", "react" ], - "version": "0.1.59", + "version": "0.1.60", "type": "module", "scripts": { "lint": "eslint", diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 93f4f0cc4..159f3c487 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -17,7 +17,7 @@ "react", "eslint" ], - "version": "1.0.21", + "version": "1.0.22", "main": "dist/index.cjs", "type": "module", "scripts": { diff --git a/packages/next-plugin/package.json b/packages/next-plugin/package.json index dace42aad..fdf7051cd 100644 --- a/packages/next-plugin/package.json +++ b/packages/next-plugin/package.json @@ -19,7 +19,7 @@ "webpack" ], "type": "module", - "version": "1.0.89", + "version": "1.0.90", "scripts": { "lint": "eslint", "build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external && bun build --target node src/loader.ts --production --env=disable --outfile dist/loader.cjs --format cjs --packages external && bun build --target node src/loader.ts --production --env=disable --outfile dist/loader.mjs --format esm --packages external && bun build --target node src/css-loader.ts --production --env=disable --outfile dist/css-loader.cjs --format cjs --packages external && bun build --target node src/css-loader.ts --production --env=disable --outfile dist/css-loader.mjs --format esm --packages external" diff --git a/packages/plugin-utils/package.json b/packages/plugin-utils/package.json index dda8c11fd..1ef59ee54 100644 --- a/packages/plugin-utils/package.json +++ b/packages/plugin-utils/package.json @@ -15,7 +15,7 @@ "utils" ], "type": "module", - "version": "1.0.16", + "version": "1.0.17", "scripts": { "lint": "eslint", "test:types": "bun ../../node_modules/@typescript/native/bin/tsc --ignoreConfig --noEmit --strict --skipLibCheck --moduleResolution bundler --module esnext --target esnext --types bun src/__tests__/shorthands.test.ts", diff --git a/packages/rsbuild-plugin/package.json b/packages/rsbuild-plugin/package.json index 6a93ef7b5..0582b4fa8 100644 --- a/packages/rsbuild-plugin/package.json +++ b/packages/rsbuild-plugin/package.json @@ -18,7 +18,7 @@ "rsbuild" ], "type": "module", - "version": "1.0.66", + "version": "1.0.67", "scripts": { "lint": "eslint", "build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external" diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index 8eeb20178..82eba9cac 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -18,7 +18,7 @@ "vite" ], "type": "module", - "version": "1.0.72", + "version": "1.0.73", "scripts": { "lint": "eslint", "build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external" diff --git a/packages/webpack-plugin/package.json b/packages/webpack-plugin/package.json index 4b34fe4b2..63b1e92dc 100644 --- a/packages/webpack-plugin/package.json +++ b/packages/webpack-plugin/package.json @@ -18,7 +18,7 @@ "webpack" ], "type": "module", - "version": "1.0.70", + "version": "1.0.71", "scripts": { "build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external && bun build --target node src/loader.ts --production --env=disable --outfile dist/loader.cjs --format cjs --packages external && bun build --target node src/loader.ts --production --env=disable --outfile dist/loader.mjs --format esm --packages external && bun build --target node src/css-loader.ts --production --env=disable --outfile dist/css-loader.cjs --format cjs --packages external && bun build --target node src/css-loader.ts --production --env=disable --outfile dist/css-loader.mjs --format esm --packages external" }, From baabf698f85d84f9c7feb3298f093199787a259d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 8 Oct 2026 23:38:15 +0900 Subject: [PATCH 8/9] fix(vite-plugin): restore main RSC CSS behavior and regression gate Restore pinned main 11790bef's parked-forwarded CSS and post-restoration plugin pair, full vinext RSC fixture, both-mode CI job, root script and resolver lock after the unchanged-tree ours merge4473674c skipped #747 content. Preserve all #763 shorthand normalization, JSX-source, StyleX, type and lint intent. Local restored RSC e2e29/29 passed under6GiB; all packages built serially and Bun6071/0fail passed. Refs #763 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .github/workflows/publish.yml | 59 +++ apps/vinext-rsc-css-repro/.gitignore | 7 + apps/vinext-rsc-css-repro/devup.json | 1 + .../e2e/shared-css.spec.ts | 157 ++++++++ apps/vinext-rsc-css-repro/next.config.ts | 4 + apps/vinext-rsc-css-repro/package.json | 26 ++ .../src/app/admins/page.tsx | 11 + apps/vinext-rsc-css-repro/src/app/layout.tsx | 9 + .../src/app/notice/page.tsx | 11 + apps/vinext-rsc-css-repro/src/app/page.tsx | 10 + .../src/app/users/edit/page.tsx | 19 + .../src/app/users/page.tsx | 13 + .../src/components/PageTitle.tsx | 25 ++ apps/vinext-rsc-css-repro/tsconfig.json | 16 + apps/vinext-rsc-css-repro/vite-env.d.ts | 2 + apps/vinext-rsc-css-repro/vite.config.ts | 10 + bun.lock | 42 +- package.json | 1 + .../vite-plugin/src/__tests__/plugin.test.ts | 366 +++++++++++++----- packages/vite-plugin/src/plugin.ts | 114 +++++- playwright.rsc-css.config.ts | 31 ++ 21 files changed, 819 insertions(+), 115 deletions(-) create mode 100644 apps/vinext-rsc-css-repro/.gitignore create mode 100644 apps/vinext-rsc-css-repro/devup.json create mode 100644 apps/vinext-rsc-css-repro/e2e/shared-css.spec.ts create mode 100644 apps/vinext-rsc-css-repro/next.config.ts create mode 100644 apps/vinext-rsc-css-repro/package.json create mode 100644 apps/vinext-rsc-css-repro/src/app/admins/page.tsx create mode 100644 apps/vinext-rsc-css-repro/src/app/layout.tsx create mode 100644 apps/vinext-rsc-css-repro/src/app/notice/page.tsx create mode 100644 apps/vinext-rsc-css-repro/src/app/page.tsx create mode 100644 apps/vinext-rsc-css-repro/src/app/users/edit/page.tsx create mode 100644 apps/vinext-rsc-css-repro/src/app/users/page.tsx create mode 100644 apps/vinext-rsc-css-repro/src/components/PageTitle.tsx create mode 100644 apps/vinext-rsc-css-repro/tsconfig.json create mode 100644 apps/vinext-rsc-css-repro/vite-env.d.ts create mode 100644 apps/vinext-rsc-css-repro/vite.config.ts create mode 100644 playwright.rsc-css.config.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cd3b62a9f..0e6edbd21 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -138,6 +138,65 @@ jobs: path: playwright-report/ retention-days: 1 + # @devup-ui/vite-plugin must keep the CSS every server page needs when the + # build is vinext/RSC. The fixture shares one Devup UI header between server + # list pages and a client page, and the tests read the styles the browser + # actually applies when each route is entered directly, so a dropped + # stylesheet dependency fails here whatever the CSS files are called. + vinext-rsc-css-e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - uses: actions-rust-lang/setup-rust-toolchain@v2 + + - name: Cache cargo registry + target + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: cargo-vinext-rsc-css-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + name: Install bun + + - name: Cache bun dependencies + uses: actions/cache@v6 + with: + path: ~/.bun/install/cache + key: bun-vinext-rsc-css-${{ runner.os }}-${{ hashFiles('**/bun.lock') }} + - uses: jetli/wasm-pack-action@v0.4.0 + with: + version: "latest" + - name: Install Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + - run: bun install + - run: bun run build + - name: Cache Playwright Browsers + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-vinext-rsc-css-${{ runner.os }}-${{ hashFiles('**/bun.lock') }} + - name: Install Playwright Browsers + run: bunx playwright install chromium --with-deps + - name: Build fixture (per-file CSS) + run: bun run --filter vinext-rsc-css-repro build + - name: Run E2E Tests (per-file CSS) + run: bun run test:e2e:rsc-css + - name: Build fixture (singleCss) + run: | + rm -rf apps/vinext-rsc-css-repro/dist apps/vinext-rsc-css-repro/.vinext apps/vinext-rsc-css-repro/df + DEVUP_SINGLE_CSS=1 bun run --filter vinext-rsc-css-repro build + - name: Run E2E Tests (singleCss) + run: bun run test:e2e:rsc-css publish: runs-on: ubuntu-latest permissions: diff --git a/apps/vinext-rsc-css-repro/.gitignore b/apps/vinext-rsc-css-repro/.gitignore new file mode 100644 index 000000000..8a6f6f50f --- /dev/null +++ b/apps/vinext-rsc-css-repro/.gitignore @@ -0,0 +1,7 @@ +/dist/ +/.vinext/ +/.next/ +/df/ +/node_modules/ +next-env.d.ts +*.tsbuildinfo diff --git a/apps/vinext-rsc-css-repro/devup.json b/apps/vinext-rsc-css-repro/devup.json new file mode 100644 index 000000000..ed9fe70ff --- /dev/null +++ b/apps/vinext-rsc-css-repro/devup.json @@ -0,0 +1 @@ +{ "theme": {} } diff --git a/apps/vinext-rsc-css-repro/e2e/shared-css.spec.ts b/apps/vinext-rsc-css-repro/e2e/shared-css.spec.ts new file mode 100644 index 000000000..6e9187c1f --- /dev/null +++ b/apps/vinext-rsc-css-repro/e2e/shared-css.spec.ts @@ -0,0 +1,157 @@ +import { expect, type Page, test } from '@playwright/test' + +const ROUTES = ['/notice/', '/admins/', '/users/', '/users/edit/'] as const +const EDIT_ROUTE = '/users/edit/' + +function permutations(items: readonly T[]): T[][] { + if (items.length <= 1) return [[...items]] + return items.flatMap((item, index) => + permutations([...items.slice(0, index), ...items.slice(index + 1)]).map( + (rest) => [item, ...rest], + ), + ) +} + +/** + * The users list links to the edit page and so prefetches it, which also loads + * the CSS its client bundle references. Refuse every request to the edit page + * except its own document, so a page can only get the header CSS from what the + * server rendered for it. + */ +async function blockEditPrefetch(page: Page) { + await page.route('**/users/edit/**', (route) => { + if (route.request().resourceType() === 'document') return route.continue() + return route.abort() + }) +} + +async function expectHeaderStyled(page: Page, route: string) { + const header = page.locator('[data-page-title]') + await expect(header, route).toHaveCount(1) + + const applied = await header.evaluate((element) => { + const read = (target: Element) => { + const style = getComputedStyle(target) + return { + display: style.display, + justifyContent: style.justifyContent, + alignItems: style.alignItems, + marginBottom: style.marginBottom, + columnGap: style.columnGap, + rowGap: style.rowGap, + } + } + const group = element.firstElementChild! + return { header: read(element), group: read(group) } + }) + expect(applied.header, `${route} header`).toMatchObject({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '20px', + }) + expect(applied.group, `${route} title group`).toMatchObject({ + display: 'flex', + alignItems: 'center', + columnGap: '12px', + rowGap: '12px', + }) + + const layout = await header.evaluate((element) => { + const bounds = element.getBoundingClientRect() + const group = element.firstElementChild!.getBoundingClientRect() + const right = element.lastElementChild!.getBoundingClientRect() + return { bounds, group, right, hasRight: element.childElementCount > 1 } + }) + expect(layout.group.left, `${route} title group is at the start`).toBe( + layout.bounds.left, + ) + if (layout.hasRight) { + expect(layout.right.right, `${route} right slot is at the end`).toBe( + layout.bounds.right, + ) + } +} + +/** + * Every class the header renders must have a rule in a stylesheet the page + * really loaded (a `` the browser fetched), whatever the files are + * called. + */ +async function expectHeaderCssLoaded(page: Page, route: string) { + const result = await page.locator('[data-page-title]').evaluate((element) => { + const classes = [ + ...element.classList, + ...element.firstElementChild!.classList, + ] + const linked = [...document.styleSheets].filter( + (sheet) => sheet.href !== null, + ) + const collect = (rules: CSSRuleList): string[] => + [...rules].flatMap((rule) => + rule instanceof CSSStyleRule + ? [rule.selectorText] + : 'cssRules' in rule + ? collect(rule.cssRules as CSSRuleList) + : [], + ) + const selectors = linked.flatMap((sheet) => collect(sheet.cssRules)) + return { + linkedSheets: linked.length, + missing: classes.filter( + (name) => + !selectors.some((selector) => + selector.includes(`.${CSS.escape(name)}`), + ), + ), + classes: classes.length, + } + }) + expect(result.classes, `${route} header has atomic classes`).toBeGreaterThan( + 0, + ) + expect(result.linkedSheets, `${route} loaded stylesheets`).toBeGreaterThan(0) + expect(result.missing, `${route} classes without a loaded rule`).toEqual([]) +} + +async function expectRoute(page: Page, route: string) { + await expectHeaderCssLoaded(page, route) + await expectHeaderStyled(page, route) +} + +test.describe('shared Devup UI header in the static vinext build', () => { + for (const route of ROUTES) { + test(`direct entry and reload: ${route}`, async ({ browser }) => { + const context = await browser.newContext() + const page = await context.newPage() + await blockEditPrefetch(page) + + await page.goto(route) + await expectRoute(page, route) + + await page.reload() + await expectRoute(page, route) + await context.close() + }) + } + + for (const order of permutations(ROUTES)) { + test(`entered in order ${order.join(' -> ')}`, async ({ browser }) => { + const context = await browser.newContext() + const page = await context.newPage() + await blockEditPrefetch(page) + + for (const route of order) { + await page.goto(route) + await expectRoute(page, route) + } + await context.close() + }) + } + + test('the edit page itself renders the same header', async ({ page }) => { + await page.goto(EDIT_ROUTE) + await expect(page.getByRole('textbox', { name: 'name' })).toBeVisible() + await expectRoute(page, EDIT_ROUTE) + }) +}) diff --git a/apps/vinext-rsc-css-repro/next.config.ts b/apps/vinext-rsc-css-repro/next.config.ts new file mode 100644 index 000000000..d989d7d9d --- /dev/null +++ b/apps/vinext-rsc-css-repro/next.config.ts @@ -0,0 +1,4 @@ +export default { + output: 'export', + trailingSlash: true, +} diff --git a/apps/vinext-rsc-css-repro/package.json b/apps/vinext-rsc-css-repro/package.json new file mode 100644 index 000000000..08e528ec5 --- /dev/null +++ b/apps/vinext-rsc-css-repro/package.json @@ -0,0 +1,26 @@ +{ + "name": "vinext-rsc-css-repro", + "version": "0.0.0", + "type": "module", + "private": true, + "scripts": { + "build": "vinext build", + "lint": "eslint" + }, + "dependencies": { + "@devup-ui/react": "workspace:^", + "react": "^19.3", + "react-dom": "^19.3", + "react-server-dom-webpack": "^19.3", + "vinext": "1.0.0" + }, + "devDependencies": { + "@devup-ui/vite-plugin": "workspace:^", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.1", + "@vitejs/plugin-rsc": "0.5.35", + "typescript": "^7.0.2", + "vite": "8.3.1" + } +} diff --git a/apps/vinext-rsc-css-repro/src/app/admins/page.tsx b/apps/vinext-rsc-css-repro/src/app/admins/page.tsx new file mode 100644 index 000000000..8e7dd4ad3 --- /dev/null +++ b/apps/vinext-rsc-css-repro/src/app/admins/page.tsx @@ -0,0 +1,11 @@ +import { PageTitle } from '../../components/PageTitle' + +export default function AdminsPage() { + return ( +
+ Create}> + Admins + +
+ ) +} diff --git a/apps/vinext-rsc-css-repro/src/app/layout.tsx b/apps/vinext-rsc-css-repro/src/app/layout.tsx new file mode 100644 index 000000000..07e2db241 --- /dev/null +++ b/apps/vinext-rsc-css-repro/src/app/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react' + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/vinext-rsc-css-repro/src/app/notice/page.tsx b/apps/vinext-rsc-css-repro/src/app/notice/page.tsx new file mode 100644 index 000000000..1ac64dcb8 --- /dev/null +++ b/apps/vinext-rsc-css-repro/src/app/notice/page.tsx @@ -0,0 +1,11 @@ +import { PageTitle } from '../../components/PageTitle' + +export default function NoticePage() { + return ( +
+ Create}> + Notice + +
+ ) +} diff --git a/apps/vinext-rsc-css-repro/src/app/page.tsx b/apps/vinext-rsc-css-repro/src/app/page.tsx new file mode 100644 index 000000000..89fb876f5 --- /dev/null +++ b/apps/vinext-rsc-css-repro/src/app/page.tsx @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default function HomePage() { + return ( +
+

Repro

+ notice +
+ ) +} diff --git a/apps/vinext-rsc-css-repro/src/app/users/edit/page.tsx b/apps/vinext-rsc-css-repro/src/app/users/edit/page.tsx new file mode 100644 index 000000000..d23d7c9f5 --- /dev/null +++ b/apps/vinext-rsc-css-repro/src/app/users/edit/page.tsx @@ -0,0 +1,19 @@ +'use client' + +import { useState } from 'react' + +import { PageTitle } from '../../../components/PageTitle' + +export default function UserEditPage() { + const [name, setName] = useState('') + return ( +
+ Edit user + setName(event.target.value)} + value={name} + /> +
+ ) +} diff --git a/apps/vinext-rsc-css-repro/src/app/users/page.tsx b/apps/vinext-rsc-css-repro/src/app/users/page.tsx new file mode 100644 index 000000000..74a0aaaf1 --- /dev/null +++ b/apps/vinext-rsc-css-repro/src/app/users/page.tsx @@ -0,0 +1,13 @@ +import Link from 'next/link' + +import { PageTitle } from '../../components/PageTitle' + +export default function UsersPage() { + return ( +
+ Create}> + Users + +
+ ) +} diff --git a/apps/vinext-rsc-css-repro/src/components/PageTitle.tsx b/apps/vinext-rsc-css-repro/src/components/PageTitle.tsx new file mode 100644 index 000000000..94b8d3845 --- /dev/null +++ b/apps/vinext-rsc-css-repro/src/components/PageTitle.tsx @@ -0,0 +1,25 @@ +import { Box, Flex, Text } from '@devup-ui/react' +import type { ReactNode } from 'react' + +export function PageTitle({ + children, + right, +}: { + children: ReactNode + right?: ReactNode +}) { + return ( + + + + {children} + + {right} + + ) +} diff --git a/apps/vinext-rsc-css-repro/tsconfig.json b/apps/vinext-rsc-css-repro/tsconfig.json new file mode 100644 index 000000000..c0b46b24f --- /dev/null +++ b/apps/vinext-rsc-css-repro/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "module": "esnext", + "moduleResolution": "bundler", + "isolatedModules": true, + "jsx": "react-jsx", + "paths": { "@/*": ["./src/*"] } + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/apps/vinext-rsc-css-repro/vite-env.d.ts b/apps/vinext-rsc-css-repro/vite-env.d.ts new file mode 100644 index 000000000..6a5a1ad59 --- /dev/null +++ b/apps/vinext-rsc-css-repro/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/apps/vinext-rsc-css-repro/vite.config.ts b/apps/vinext-rsc-css-repro/vite.config.ts new file mode 100644 index 000000000..9f163aa1a --- /dev/null +++ b/apps/vinext-rsc-css-repro/vite.config.ts @@ -0,0 +1,10 @@ +import { DevupUI } from '@devup-ui/vite-plugin' +import vinext from 'vinext' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + DevupUI({ singleCss: process.env.DEVUP_SINGLE_CSS === '1' }), + vinext(), + ], +}) diff --git a/bun.lock b/bun.lock index 298725c65..906ce66d6 100644 --- a/bun.lock +++ b/bun.lock @@ -92,6 +92,26 @@ "@rsbuild/plugin-react": "^2.1", }, }, + "apps/vinext-rsc-css-repro": { + "name": "vinext-rsc-css-repro", + "version": "0.0.0", + "dependencies": { + "@devup-ui/react": "workspace:^", + "react": "^19.3", + "react-dom": "^19.3", + "react-server-dom-webpack": "^19.3", + "vinext": "1.0.0", + }, + "devDependencies": { + "@devup-ui/vite-plugin": "workspace:^", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.1", + "@vitejs/plugin-rsc": "0.5.35", + "typescript": "^7.0.2", + "vite": "8.3.1", + }, + }, "apps/vite": { "name": "vite-example", "version": "0.1.0", @@ -2265,7 +2285,7 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fflate": ["fflate@0.7.5", "", {}, "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g=="], + "fflate": ["fflate@0.7.3", "", {}, "sha512-0Zz1jOzJWERhyhsimS54VTqOteCNwRtIlh8isdL0AXLo0g7xNTfTL7oWrkmCnPhZGocKIkWHBistBrrpoNH3aw=="], "file-entry-cache": ["file-entry-cache@11.1.5", "", { "dependencies": { "flat-cache": "^6.1.23" } }, "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q=="], @@ -2313,6 +2333,8 @@ "happy-dom": ["happy-dom@20.14.5", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ=="], + "harfbuzzjs": ["harfbuzzjs@0.10.0", "", {}, "sha512-SN8LVwCOzvTq3OPNd0+EAghgXugItz56wst567D1vs7LnnKGWprhi3EG58aVOBwhN8HEbQxL4I9NDWkcXUutRw=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="], @@ -3093,6 +3115,8 @@ "vinext-devup-ui-benchmark": ["vinext-devup-ui-benchmark@workspace:benchmark/vinext-devup-ui"], + "vinext-rsc-css-repro": ["vinext-rsc-css-repro@workspace:apps/vinext-rsc-css-repro"], + "vite": ["vite@8.3.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.9", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA=="], "vite-example": ["vite-example@workspace:apps/vite"], @@ -3621,6 +3645,8 @@ "@rspack/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + "@shuding/opentype.js/fflate": ["fflate@0.7.5", "", {}, "sha512-QieYf//cis6ywHNi5qW1+PXPQ4bC+XVJAtS4AXIML8P76GroEiOxm/oQtn1f02UkJY1+KsXMJcC+R2v/Eg4G3g=="], + "@stylexjs/babel-plugin/@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], "@stylexjs/babel-plugin/@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], @@ -3781,6 +3807,8 @@ "vinext/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "vinext-rsc-css-repro/vinext": ["vinext@1.0.0", "", { "dependencies": { "@unpic/react": "^1.0.2", "@vercel/og": "1.0.3", "@vinext/types": "^1.0.0", "ipaddr.js": "^2.1.0", "magic-string": "^0.30.21", "vite-plugin-commonjs": "^0.10.4", "web-vitals": "^4.2.4" }, "peerDependencies": { "@mdx-js/rollup": "^3.0.0", "@vitejs/plugin-react": "^5.1.4 || ^6.0.0", "@vitejs/plugin-rsc": "^0.5.34", "react": "^19.2.6", "react-dom": "^19.2.6", "react-server-dom-webpack": "^19.2.6", "vite": "^8.0.0" }, "optionalPeers": ["@mdx-js/rollup", "@vitejs/plugin-rsc", "react-server-dom-webpack"], "bin": { "vinext": "dist/cli.js" } }, "sha512-0PeWLqMoJO6mkPvprneY1mLY12nxxhaGTdr3+sTfVTD9xncwaIXQyhVWy+vrvgMlTOkg2e8MMgOiI3qnEdMp2w=="], + "vite-node/cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], "vite-node/obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], @@ -5631,6 +5659,12 @@ "vfile-reporter/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "vinext-rsc-css-repro/vinext/@vercel/og": ["@vercel/og@1.0.3", "", { "dependencies": { "@resvg/resvg-wasm": "2.4.1", "satori": "0.33.5" }, "optionalDependencies": { "sharp": "^0.35.3" } }, "sha512-/iYJ34PDu4lty1TXdL86qHEw8viveHTllG1PWLuJX8tSFfqKL7kyVJeUORJmQ250bHB0h+alcalXUlza3c/NSA=="], + + "vinext-rsc-css-repro/vinext/@vinext/types": ["@vinext/types@1.0.1", "", {}, "sha512-zH4WbVl47rNGxG8Y7XoWJAc8e0VQFQ+r8Cuh0crv1L2SuNovDD3jMtxQDjzoS1b9hYaZXbds0CjRo5tSm7F9Zw=="], + + "vinext-rsc-css-repro/vinext/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "@babel/helper-create-class-features-plugin/@babel/core/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -6577,6 +6611,10 @@ "vfile-reporter/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + "vinext-rsc-css-repro/vinext/@vercel/og/@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.4.1", "", {}, "sha512-yi6R0HyHtsoWTRA06Col4WoDs7SvlXU3DLMNP2bdAgs7HK18dTEVl1weXgxRzi8gwLteGUbIg29zulxIB3GSdg=="], + + "vinext-rsc-css-repro/vinext/@vercel/og/satori": ["satori@0.33.5", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "fflate": "0.7.3", "harfbuzzjs": "0.10.0", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-PNW6ktrQ8SM6kk7u4aOuTkkUewkzyTOrj+QeEzXSsnNMAdff4ire9CAqIz8+4C3rCiBtK8QTELv52AZEd1PbRw=="], + "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "@kuma-ui/compiler/ts-morph/@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], @@ -6593,6 +6631,8 @@ "unified-engine/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "vinext-rsc-css-repro/vinext/@vercel/og/satori/css-gradient-parser": ["css-gradient-parser@0.0.17", "", {}, "sha512-w2Xy9UMMwlKtou0vlRnXvWglPAceXCTtcmVSo8ZBUvqCV5aXEFP/PC6d+I464810I9FT++UACwTD5511bmGPUg=="], + "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "@kuma-ui/compiler/ts-morph/@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/package.json b/package.json index 5d14f9fbe..795d7a498 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test": "cargo tarpaulin --out xml --out stdout --all-targets --engine llvm $(node -p \"process.env.CI ? '--fail-under 100' : ''\") && bun test", "test:e2e": "bunx playwright test", "test:e2e:next": "LANDING_BUILD_MODE=next bunx playwright test", + "test:e2e:rsc-css": "bunx playwright test -c playwright.rsc-css.config.ts", "test:e2e:ui": "bunx playwright test --ui", "test:e2e:update": "bunx playwright test --update-snapshots", "build": "bun run --filter @devup-ui/wasm --filter @devup-ui/plugin-utils build && bun run --filter @devup-ui/react --filter @devup-ui/webpack-plugin build && bun run --filter @devup-ui/eslint-plugin --filter @devup-ui/vite-plugin --filter @devup-ui/next-plugin --filter @devup-ui/rsbuild-plugin --filter @devup-ui/bun-plugin --filter @devup-ui/components --filter @devup-ui/reset-css build", diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index a36848cfa..4658170c2 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -95,6 +95,7 @@ interface ViteTestPlugin { configResolved: (config?: { command?: 'serve' | 'build' root?: string + plugins?: object[] }) => Promise watchChange: (id: string) => Promise hotUpdate: ( @@ -149,6 +150,16 @@ interface ViteTestPlugin { resolveId: (source: string, importer?: string) => string | undefined } +interface ViteTestRestorePlugin { + name: string + sharedDuringBuild: true + apply: 'build' + generateBundle: { + order: 'post' + handler: (options: object, bundle: Record) => void + } +} + function createCodeExtractResult( overrides: Partial = {}, ): CodeExtractResult { @@ -164,8 +175,14 @@ function createCodeExtractResult( } as unknown as CodeExtractResult } +function createPlugins( + options?: Parameters[0], +): [ViteTestPlugin, ViteTestRestorePlugin] { + return DevupUI(options) as unknown as [ViteTestPlugin, ViteTestRestorePlugin] +} + function createPlugin(options?: Parameters[0]): ViteTestPlugin { - return DevupUI(options) as unknown as ViteTestPlugin + return createPlugins(options)[0] } const ROLLUP_META: ConfigHookMeta = { @@ -649,115 +666,270 @@ describe('devupUIVitePlugin', () => { expect(bundle['base.css'].source).toEqual('final complete sheet') }) - it('does not forward server css that the client already emits', async () => { - const plugin = createPlugin({}) - getCssSpy.mockImplementation((fileNum: number | null) => - fileNum === null ? 'base sheet' : 'file sheet', - ) - const serverBundle = { - 'base.css': { source: 'stale', name: 'devup-ui.css' }, - 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, - 'entry.js': { - name: 'entry', - viteMetadata: { - importedCss: new Set(['base.css', 'file.css', 'server-only.css']), - }, - }, + describe('css forwarded from the server bundle by @vitejs/plugin-rsc', () => { + interface Asset { + type: 'asset' + fileName: string + source: string + name: string } - const clientBundle = { - 'base.css': { source: 'stale', name: 'devup-ui.css' }, - 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, + interface Chunk { + type: 'chunk' + fileName: string + name: string + viteMetadata?: { importedCss?: Set } + } + type Output = Asset | Chunk + type Bundle = Record + + const asset = (fileName: string, name: string): Asset => ({ + type: 'asset', + fileName, + source: 'stale', + name, + }) + const chunk = (fileName: string, css: string[]): Chunk => ({ + type: 'chunk', + fileName, + name: fileName, + viteMetadata: { importedCss: new Set(css) }, + }) + const serverEnv = { + environment: { name: 'rsc', config: { consumer: 'server' as const } }, + } + const clientEnv = { + environment: { + name: 'client', + config: { consumer: 'client' as const }, + }, } + const rscConfig = (bundles: Record) => ({ + plugins: [{ name: 'rsc:minimal', api: { manager: { bundles } } }], + }) - await plugin.generateBundle.call( - { environment: { name: 'rsc', config: { consumer: 'server' } } }, - {}, - serverBundle, - ) - await plugin.generateBundle.call( - { environment: { name: 'client', config: { consumer: 'client' } } }, - {}, - clientBundle, - ) + /** + * What plugin-rsc does in the client `generateBundle`: emit every CSS file + * the server chunks import (under the asset's own fileName), then read the + * very same imports as the server pages' CSS dependencies. + */ + function forwardLikePluginRsc(rscBundle: Bundle) { + const imported = Object.values(rscBundle).flatMap((output) => + output.type === 'chunk' + ? [...(output.viteMetadata?.importedCss ?? [])] + : [], + ) + const emitted = [...new Set(imported)].map( + (file) => rscBundle[file].fileName, + ) + const dependencies = Object.fromEntries( + Object.values(rscBundle).flatMap((output) => + output.type === 'chunk' + ? [ + [ + output.fileName, + [...(output.viteMetadata?.importedCss ?? [])], + ], + ] + : [], + ), + ) + return { emitted, dependencies } + } - expect(serverBundle['base.css'].source).toEqual('base sheet') - expect(serverBundle['file.css'].source).toEqual('file sheet') - expect(clientBundle['base.css'].source).toEqual('base sheet') - expect(clientBundle['file.css'].source).toEqual('file sheet') - expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual( - new Set(['server-only.css']), - ) - }) + beforeEach(() => { + getCssSpy.mockImplementation((fileNum: number | null) => + fileNum === null ? 'base sheet' : 'file sheet', + ) + }) - it('ignores no-write analysis bundles when tracking server css', async () => { - const plugin = createPlugin({}) - const serverBundle = { - 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, - 'entry.js': { - name: 'entry', - viteMetadata: { importedCss: new Set(['file.css']) }, - }, - } - await plugin.generateBundle.call( + it.each([ { - environment: { - name: 'rsc', - config: { consumer: 'server', build: { write: false } }, - }, + label: 'per-file css', + singleCss: false, + shared: ['base.css', 'file.css'], + }, + { label: 'single css', singleCss: true, shared: ['base.css'] }, + ])( + 'keeps the server css dependencies and emits each file once ($label)', + async ({ singleCss, shared }) => { + const [plugin, restore] = createPlugins({ singleCss }) + const names: Record = { + 'base.css': 'devup-ui.css', + 'file.css': 'devup-ui-3.css', + } + const serverBundle: Bundle = { + 'base.css': asset('base.css', names['base.css']), + 'file.css': asset('file.css', names['file.css']), + 'server-only.css': asset('server-only.css', 'server-only.css'), + 'page.js': chunk('page.js', [...shared, 'server-only.css']), + } + const clientBundle: Bundle = Object.fromEntries( + shared.map((file) => [file, asset(file, names[file])]), + ) + await plugin.configResolved( + rscConfig({ rsc: serverBundle, client: clientBundle }), + ) + + await plugin.generateBundle.call(serverEnv, {}, serverBundle) + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + const { emitted, dependencies } = forwardLikePluginRsc(serverBundle) + + expect(dependencies['page.js']).toEqual([ + ...shared, + 'server-only.css', + ]) + expect(emitted).toHaveLength(new Set(emitted).size) + expect(emitted.filter((name) => name in clientBundle)).toEqual([]) + expect(emitted).toContain('server-only.css') + + for (const name of emitted) clientBundle[name] = asset(name, name) + restore.generateBundle.handler({}, clientBundle) + + expect(Object.keys(clientBundle).sort()).toEqual( + [...shared, 'server-only.css'].sort(), + ) + for (const file of shared) { + expect(serverBundle[file].fileName).toBe(file) + } + expect(dependencies['page.js']).toEqual([ + ...(serverBundle['page.js'] as Chunk).viteMetadata!.importedCss!, + ]) + expect(clientBundle['base.css']).toHaveProperty( + 'source', + 'base sheet', + ) }, - {}, - serverBundle, ) - const clientBundle = { - 'file.css': { source: 'stale', name: 'devup-ui-3.css' }, - } - await plugin.generateBundle.call( - { environment: { name: 'client', config: { consumer: 'client' } } }, - {}, - clientBundle, - ) + it('undoes the stand-ins only once', async () => { + const [plugin, restore] = createPlugins() + const serverBundle: Bundle = { + 'base.css': asset('base.css', 'devup-ui.css'), + 'page.js': chunk('page.js', ['base.css']), + } + const clientBundle: Bundle = { + 'base.css': asset('base.css', 'devup-ui.css'), + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + expect(serverBundle['base.css'].fileName).not.toBe('base.css') - expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual( - new Set(['file.css']), - ) - }) + restore.generateBundle.handler({}, clientBundle) + expect(serverBundle['base.css'].fileName).toBe('base.css') - it('keeps server forwarding for a different output file name', async () => { - const plugin = createPlugin({}) - const serverBundle = { - 'devup-ui-3.server.css': { - source: 'stale', - name: 'devup-ui-3.css', + serverBundle['base.css'].fileName = 'changed.css' + restore.generateBundle.handler({}, clientBundle) + expect(serverBundle['base.css'].fileName).toBe('changed.css') + }) + + it('leaves css the client does not emit to plugin-rsc as it is', async () => { + const [plugin] = createPlugins() + const serverBundle: Bundle = { + 'devup-ui-3.server.css': asset( + 'devup-ui-3.server.css', + 'devup-ui-3.css', + ), + 'page.js': chunk('page.js', ['devup-ui-3.server.css']), + } + const clientBundle: Bundle = { + 'devup-ui-3.client.css': asset( + 'devup-ui-3.client.css', + 'devup-ui-3.css', + ), + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + + expect(forwardLikePluginRsc(serverBundle).emitted).toEqual([ + 'devup-ui-3.server.css', + ]) + }) + + it('does not park a server entry that is not an asset', async () => { + const [plugin] = createPlugins() + const serverBundle: Bundle = { + 'shared.css': chunk('shared.css', []), + 'page.js': chunk('page.js', ['shared.css']), + 'plain.js': { type: 'chunk', fileName: 'plain.js', name: 'plain' }, + } + const clientBundle: Bundle = { + 'shared.css': asset('shared.css', 'shared.css'), + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + + expect(serverBundle['shared.css'].fileName).toBe('shared.css') + }) + + it('does not touch the client bundle the rsc plugin tracks', async () => { + const [plugin] = createPlugins() + const trackedClient: Bundle = { + 'base.css': asset('base.css', 'devup-ui.css'), + 'page.js': chunk('page.js', ['base.css']), + } + const clientBundle: Bundle = { + 'base.css': asset('base.css', 'devup-ui.css'), + } + await plugin.configResolved(rscConfig({ client: trackedClient })) + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + + expect(trackedClient['base.css'].fileName).toBe('base.css') + }) + + it.each([ + { label: 'no config', config: undefined }, + { label: 'no rsc plugin', config: { plugins: [{ name: 'other' }] } }, + { + label: 'no api', + config: { plugins: [{ name: 'rsc:minimal' }] }, }, - 'entry.js': { - name: 'entry', - viteMetadata: { - importedCss: new Set(['devup-ui-3.server.css']), - }, + { + label: 'no manager bundles', + config: { plugins: [{ name: 'rsc:minimal', api: { manager: {} } }] }, }, - } - await plugin.generateBundle.call( - { environment: { name: 'rsc', config: { consumer: 'server' } } }, - {}, - serverBundle, - ) - const clientBundle = { - 'devup-ui-3.client.css': { - source: 'stale', - name: 'devup-ui-3.css', + ])( + 'does nothing when plugin-rsc exposes no bundles ($label)', + async ({ config }) => { + const [plugin, restore] = createPlugins() + const clientBundle: Bundle = { + 'base.css': asset('base.css', 'devup-ui.css'), + } + await plugin.configResolved(config) + + await plugin.generateBundle.call(clientEnv, {}, clientBundle) + restore.generateBundle.handler({}, clientBundle) + + expect(Object.keys(clientBundle)).toEqual(['base.css']) }, - } - - await plugin.generateBundle.call( - { environment: { name: 'client', config: { consumer: 'client' } } }, - {}, - clientBundle, ) - expect(serverBundle['entry.js'].viteMetadata.importedCss).toEqual( - new Set(['devup-ui-3.server.css']), - ) + it('ignores no-write analysis bundles', async () => { + const [plugin] = createPlugins() + const serverBundle: Bundle = { + 'base.css': asset('base.css', 'devup-ui.css'), + 'page.js': chunk('page.js', ['base.css']), + } + const clientBundle: Bundle = { + 'base.css': asset('base.css', 'devup-ui.css'), + } + await plugin.configResolved(rscConfig({ rsc: serverBundle })) + + await plugin.generateBundle.call( + { + environment: { + name: 'client', + config: { consumer: 'client', build: { write: false } }, + }, + }, + {}, + clientBundle, + ) + + expect(serverBundle['base.css'].fileName).toBe('base.css') + }) }) it('resolves a stable id during build', async () => { @@ -1350,9 +1522,9 @@ describe('devupUIVitePlugin atom hoisting', () => { options: Parameters[0], config: unknown, ) => { - const plugin = DevupUI(options) as unknown as { - configResolved: ConfigResolved - } + const [plugin] = DevupUI(options) as unknown as [ + { configResolved: ConfigResolved }, + ] await plugin.configResolved(config) } diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 7c6b3b508..003ea0796 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -38,7 +38,9 @@ import { import type { EnvironmentModuleNode, ModuleNode, + Plugin, PluginOption, + ResolvedConfig, UserConfig, } from 'vite' @@ -124,11 +126,77 @@ interface ConfigHookMeta { } interface ViteOutputWithMetadata { + type?: string + fileName?: string viteMetadata?: { importedCss?: Set } } +type ForwardableBundle = Record + +/** + * Name a CSS file is parked under while @vitejs/plugin-rsc forwards it, chosen + * so that nothing treats it as a stylesheet and it cannot meet a real output. + */ +function getForwardStandInName(file: string): string { + return `${file}.devup-forwarded` +} + +/** + * @vitejs/plugin-rsc copies every CSS file its server bundle references into + * the client output with `emitFile`. For a file the client already emitted that + * is a FILE_NAME_CONFLICT, and the copy replaces the client's own, dropping the + * names its build manifest is keyed by. The same plugin reads the server + * bundle's `importedCss` to learn which CSS each server page depends on, so + * that metadata has to stay whole. + * + * It takes what it forwards from `bundle[file]`. For each file the client + * already owns, the server bundle gets a stand-in copy under another name: the + * forward lands on a throwaway file and the real one is emitted once. + * + * Returns the undo that puts the originals back and drops the stand-ins. + */ +function parkForwardedCss( + clientBundle: ForwardableBundle, + serverBundle: ForwardableBundle, +): (outputBundle: ForwardableBundle) => void { + const files = new Set() + for (const output of Object.values(serverBundle)) { + for (const file of output.viteMetadata?.importedCss ?? []) { + if (file in clientBundle && serverBundle[file]?.type === 'asset') { + files.add(file) + } + } + } + for (const file of files) { + serverBundle[file].fileName = getForwardStandInName(file) + } + return (outputBundle) => { + for (const file of files) { + serverBundle[file].fileName = file + delete outputBundle[getForwardStandInName(file)] + } + } +} + +interface RscPluginApi { + manager?: { bundles?: Record } +} + +/** + * The server bundles @vitejs/plugin-rsc itself tracks (and later reads to + * forward CSS). Bundle objects handed to a plugin are per-plugin views, and + * only the ones plugin-rsc holds reflect an edit it will see. + */ +function getRscServerBundles(config: ResolvedConfig | undefined) { + const rsc = config?.plugins.find((plugin) => plugin.name === 'rsc:minimal') + const bundles = (rsc?.api as RscPluginApi | undefined)?.manager?.bundles + return Object.entries(bundles ?? {}) + .filter(([name]) => name !== 'client') + .map(([, bundle]) => bundle) +} + /** * Vite merges a plugin's `config()` result over the user's, replacing function * values outright, so returning a bare `manualChunks` silently drops one the @@ -281,7 +349,11 @@ export function DevupUI({ readJsxImportSource(), ) const cssMap = new Map() - let serverBundleToForward: Record | undefined + let resolvedConfig: ResolvedConfig | undefined + // Set by the client `generateBundle`, run by the late hook of the sibling + // plugin once @vitejs/plugin-rsc has forwarded. + let restoreForwardedCss: + ((outputBundle: ForwardableBundle) => void) | undefined let isServe = false // The dev server watches cssDir, so every write is an update signal. A // module transformed again writes its sheet again, and the reload that @@ -292,13 +364,14 @@ export function DevupUI({ writtenCss.set(fileName, css) return writeFile(join(cssDir, fileName), css, 'utf-8') } - return { + const plugin: Plugin = { name: 'devup-ui', // The WASM sheet and transform state are intentionally shared. Vite // otherwise recreates this plugin for every environment build, which makes // each environment independently emit the same CSS asset. sharedDuringBuild: true, async configResolved(config) { + resolvedConfig = config isServe = config?.command === 'serve' const projectRoot = config?.root ?? process.cwd() // Vite ids are POSIX absolute paths @@ -601,23 +674,30 @@ export function DevupUI({ const environment = this.environment if (!environment || !writesOutput) return - if (environment.config.consumer === 'client' && serverBundleToForward) { - // @vitejs/plugin-rsc forwards every CSS file referenced by the RSC - // bundle into the client bundle. Files the client already emitted are - // registered twice and trigger FILE_NAME_CONFLICT. Keep both bundles' - // imports and client metadata intact, but remove overlaps from the RSC - // forwarding set before its later generateBundle hook reads it. - for (const output of Object.values(serverBundleToForward)) { - for (const file of cssFiles) { - output.viteMetadata?.importedCss?.delete(file) - } + if (environment.config.consumer === 'client') { + const undo = getRscServerBundles(resolvedConfig).map((serverBundle) => + parkForwardedCss( + bundle as unknown as ForwardableBundle, + serverBundle, + ), + ) + restoreForwardedCss = (outputBundle) => { + for (const restore of undo) restore(outputBundle) } - } else if (environment.config.consumer === 'server') { - serverBundleToForward = bundle as unknown as Record< - string, - ViteOutputWithMetadata - > } }, } + const restorePlugin: Plugin = { + name: 'devup-ui:restore-forwarded-css', + sharedDuringBuild: true, + apply: 'build', + generateBundle: { + order: 'post', + handler(_options, bundle) { + restoreForwardedCss?.(bundle as unknown as ForwardableBundle) + restoreForwardedCss = undefined + }, + }, + } + return [plugin, restorePlugin] } diff --git a/playwright.rsc-css.config.ts b/playwright.rsc-css.config.ts new file mode 100644 index 000000000..57b9579d5 --- /dev/null +++ b/playwright.rsc-css.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test' + +/** + * Gates the static output of apps/vinext-rsc-css-repro (vinext + RSC). Build + * it first: `bun run --filter vinext-rsc-css-repro build`, or with + * DEVUP_SINGLE_CSS=1 for the singleCss variant. + */ +export default defineConfig({ + testDir: './apps/vinext-rsc-css-repro/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 2 : 4, + reporter: 'list', + timeout: 60_000, + use: { + baseURL: 'http://localhost:3187', + trace: 'on-first-retry', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'node e2e/serve-static.mjs 3187', + env: { + LANDING_OUTPUT_ROOT: 'apps/vinext-rsc-css-repro/dist/client', + }, + url: 'http://localhost:3187', + reuseExistingServer: false, + timeout: 60_000, + stdout: 'pipe', + }, +}) From 366e4a353a10098380c334c1ff057ff63e5a30c3 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 24 Oct 2026 02:08:56 +0900 Subject: [PATCH 9/9] test(eslint-plugin): cover remaining rule branches under the latest Bun Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../__tests__/index.test.ts | 15 +++++++ .../__tests__/numeric-value.test.ts | 40 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 packages/eslint-plugin/src/rules/style-order-range/__tests__/numeric-value.test.ts diff --git a/packages/eslint-plugin/src/rules/no-useless-responsive/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/no-useless-responsive/__tests__/index.test.ts index ccdfba6af..52f160ea7 100644 --- a/packages/eslint-plugin/src/rules/no-useless-responsive/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/no-useless-responsive/__tests__/index.test.ts @@ -16,6 +16,14 @@ describe('no-useless-responsive rule', () => { }) ruleTester.run('no-useless-responsive rule', noUselessResponsive, { valid: [ + { + code: 'import { Box } from "@devup-ui/react";\n', + filename: 'src/app/page.tsx', + }, + { + code: 'import { css } from "@devup-ui/react";\ncss({ w: active && [1] })', + filename: 'src/app/page.tsx', + }, { code: 'import { Box } from "@devup-ui/react";\n', filename: 'src/app/page.tsx', @@ -102,6 +110,13 @@ describe('no-useless-responsive rule', () => { })), ], invalid: [ + { + code: 'import { Box } from "@devup-ui/react";\n', + output: + 'import { Box } from "@devup-ui/react";\n', + filename: 'src/app/page.tsx', + errors: [{ messageId: 'uselessResponsive' }], + }, { code: 'import { Box } from "@devup-ui/react";\n} m={[2]} />', output: diff --git a/packages/eslint-plugin/src/rules/style-order-range/__tests__/numeric-value.test.ts b/packages/eslint-plugin/src/rules/style-order-range/__tests__/numeric-value.test.ts new file mode 100644 index 000000000..99faf1474 --- /dev/null +++ b/packages/eslint-plugin/src/rules/style-order-range/__tests__/numeric-value.test.ts @@ -0,0 +1,40 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { describe } from 'bun:test' + +import { styleOrderRange } from '../index' + +describe('style-order-range unary coercion', () => { + const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }) + + ruleTester.run('style-order-range unary coercion', styleOrderRange, { + valid: [ + { + code: 'import { Box } from "@devup-ui/react";\n', + filename: 'src/app/page.tsx', + }, + { + code: 'import { css } from "@devup-ui/react";\ncss({ styleOrder: +true })', + filename: 'src/app/page.tsx', + }, + ], + invalid: [ + ...['+1n', '+/2/', '+`${2}`'].map((value) => ({ + code: `import { Box } from "@devup-ui/react";\n`, + filename: 'src/app/page.tsx', + output: null, + errors: [{ messageId: 'styleOrderRange' }], + })), + ...['-1n', '-/2/', '-`${2}`'].map((value) => ({ + code: `import { css } from "@devup-ui/react";\ncss({ styleOrder: ${value} })`, + filename: 'src/app/page.tsx', + output: null, + errors: [{ messageId: 'styleOrderRange' }], + })), + ], + }) +})