From c59d50643ffa3caf2bf183c281d05348381c7441 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 22 Oct 2026 23:59:00 +0900 Subject: [PATCH 1/4] test: restore complete coverage on latest Bun Refs #683 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_bun_latest_coverage.json | 7 ++++ .../__tests__/index.test.ts | 39 +++++++++++++++++ .../__tests__/index.test.ts | 16 +++++++ .../rules/no-useless-tailing-nulls/index.ts | 14 ++++--- .../utils/__tests__/import-sources.test.ts | 24 +++++++++++ .../src/__tests__/coordinator-port.test.ts | 6 +++ .../next-plugin/src/__tests__/wasm.test.ts | 14 +++++++ .../__tests__/reservation-discovery.test.ts | 37 ++++++++++++++++ .../src/__tests__/reservation-errors.test.ts | 29 ++++++++++++- .../src/stores/__tests__/theme-store.test.ts | 42 +++++++++++++++++++ .../src/__tests__/config-errors.test.ts | 40 ++++++++++++++++++ .../src/__tests__/plugin.test.ts | 32 ++++++++------ 12 files changed, 280 insertions(+), 20 deletions(-) create mode 100644 .changepacks/changepack_log_bun_latest_coverage.json create mode 100644 packages/webpack-plugin/src/__tests__/config-errors.test.ts diff --git a/.changepacks/changepack_log_bun_latest_coverage.json b/.changepacks/changepack_log_bun_latest_coverage.json new file mode 100644 index 000000000..8989e4049 --- /dev/null +++ b/.changepacks/changepack_log_bun_latest_coverage.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/eslint-plugin/package.json": "Patch" + }, + "note": "Preserve trailing-null lint fixes while removing an unreachable AST guard; restore complete behavior coverage on the latest Bun without exclusions or new build errors.", + "date": "2026-10-22T00:00:00.000Z" +} diff --git a/packages/eslint-plugin/src/rules/css-utils-literal-only/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/css-utils-literal-only/__tests__/index.test.ts index fc7fe8169..d4686b857 100644 --- a/packages/eslint-plugin/src/rules/css-utils-literal-only/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/css-utils-literal-only/__tests__/index.test.ts @@ -18,6 +18,25 @@ describe.each(['css' /* 'globalCss', 'keyframes'*/])( }) ruleTester.run('css-utils-literal-only rule', cssUtilsLiteralOnly, { valid: [ + ...[ + "const f = ({'p': n}) => n; css({w: f({p: 1})});", + "const k = 'p'; const f = ({[k]: n}) => n; css({w: f({p: 1})});", + 'const L = [1]; unknown(L[0]); css({w: L[0]});', + 'const L = [1]; unknown(L[9]); css({w: L[0]});', + 'const L = {p: 1}; export const holder = [L]; css({w: L.p});', + 'const L = {p: 1}; module.exports.token = L; css({w: L.p});', + "const L = {p: 1}; module['exports'].token = L; css({w: L.p});", + 'const N = 4; function f() { return N } css({w: f()});', + 'const helper = () => 4; function f() { return helper() } css({w: f()});', + 'const helper = function() { return 4 }; function f() { return helper() } css({w: f()});', + ].map((use) => ({ + code: `import { css } from "@devup-ui/react";\n${use}`, + filename: 'src/app/page.tsx', + })), + { + code: `import { 'create' as create } from '@stylexjs/stylex'; create({a: {width: 1}});`, + filename: 'src/app/page.tsx', + }, { code: `import { ${code} } from "@devup-ui/react";\n${code}({w: 1})`, filename: 'src/app/page.tsx', @@ -145,6 +164,26 @@ describe.each(['css' /* 'globalCss', 'keyframes'*/])( }, ], invalid: [ + ...[ + "const f = ({'normalize': n}) => n; css({w: f({normalize: 1})});", + 'const L = [{p: 1}]; unknown(L[0]); css({w: L[0].p});', + 'const A = [{p: 1}]; const L = [1, ...A]; unknown(L[0]); css({w: A[0].p});', + 'const L = {p: 1}; unknown([L]); css({w: L.p});', + 'const L = {p: 1}; other.exports.token = L; css({w: L.p});', + 'const helper = () => Date.now(); function f() { return helper() } css({w: f()});', + 'class Token {} function f() { return Token } css({w: f()});', + ].map((use) => ({ + code: `import { css } from "@devup-ui/react";\n${use}`, + filename: 'src/app/page.tsx', + output: null, + errors: [{ messageId: 'cssUtilsLiteralOnly' as const }], + })), + { + code: `import { 'create' as create } from '@stylexjs/stylex'; let v = 1; create({a: {width: v}});`, + filename: 'src/app/page.tsx', + output: null, + errors: [{ messageId: 'cssUtilsLiteralOnly' }], + }, ...[ [ `export function C() { let fade = keyframes({ from: { opacity: 0 } }); return css({ animationName: fade }); }`, diff --git a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts index 8af51b383..90decccc7 100644 --- a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts @@ -16,6 +16,11 @@ describe('no-useless-tailing-nulls rule', () => { }) ruleTester.run('no-useless-tailing-nulls rule', noUselessTailingNulls, { valid: [ + // Real parser holes are absent elements, not Literal null nodes. + ...['[]', '[,]', '[,null]', '[null,,]', '[1, null, 2]'].map((value) => ({ + code: `import { css } from "@devup-ui/react";\ncss({w: ${value}})`, + filename: 'src/app/page.tsx', + })), { code: 'import { Box } from "@devup-ui/react";\n', filename: 'src/app/page.tsx', @@ -44,6 +49,17 @@ describe('no-useless-tailing-nulls rule', () => { })), ], invalid: [ + ...[ + ['[null]', '[]'], + ['[null,null]', '[]'], + ['[1,null]', '[1]'], + ['[1,,null]', '[]'], + ].map(([value, fixed]) => ({ + code: `import { css } from "@devup-ui/react";\ncss({w: ${value}})`, + output: `import { css } from "@devup-ui/react";\ncss({w: ${fixed}})`, + filename: 'src/app/page.tsx', + errors: [{ messageId: 'uselessTailingNulls' as const }], + })), { code: 'import { Box } from "@devup-ui/react";\n} m={[2, null]} />', output: 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..d04e82840 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 @@ -17,8 +17,14 @@ function checkUselessTailingNulls>( node: TSESTree.ArrayExpression, context: T, ) { - let nullCount = 0 - for (let i = node.elements.length - 1; i >= 0; i--) { + const lastElement = node.elements[node.elements.length - 1] + if ( + lastElement?.type !== AST_NODE_TYPES.Literal || + lastElement.value !== null + ) + return + let nullCount = 1 + for (let i = node.elements.length - 2; i >= 0; i--) { const element = node.elements[i] if (element?.type === AST_NODE_TYPES.Literal && element.value === null) { nullCount++ @@ -26,12 +32,8 @@ function checkUselessTailingNulls>( break } } - if (nullCount === 0) return const previousElement = node.elements[node.elements.length - nullCount - 1] const firstElement = node.elements[0] - const lastElement = node.elements[node.elements.length - 1] - if (!lastElement || (node.elements.length === nullCount && !firstElement)) - return const removeStart = node.elements.length > nullCount && previousElement ? previousElement.range[1] diff --git a/packages/eslint-plugin/src/utils/__tests__/import-sources.test.ts b/packages/eslint-plugin/src/utils/__tests__/import-sources.test.ts index ad5371e35..c39c935ac 100644 --- a/packages/eslint-plugin/src/utils/__tests__/import-sources.test.ts +++ b/packages/eslint-plugin/src/utils/__tests__/import-sources.test.ts @@ -41,6 +41,30 @@ function inspect( } describe('import sources', () => { + for (const use of ['css({w: 1})', 'styled(Base, {w: 1})']) + inspect( + `import { css, styled } from "@devup-ui/react";\n${use}`, + 'page.tsx', + (storage, context) => { + const statement = context.sourceCode.ast.body[1] + if ( + statement.type !== 'ExpressionStatement' || + statement.expression.type !== 'CallExpression' + ) + throw new Error('Expected a parsed style call') + const call = statement.expression + expect(styleValueSite(call.callee, storage)).toBeNull() + const rules = call.arguments.at(-1) + if (!rules) throw new Error('Expected a rules argument') + expect(styleValueSite(rules, storage)).toEqual({ + root: call, + start: call, + }) + if (call.arguments.length === 2) + expect(styleValueSite(call.arguments[0], storage)).toBeNull() + }, + ) + inspect( [ 'import { css as a, Box as B, unknown as u } from "@devup-ui/react";', diff --git a/packages/next-plugin/src/__tests__/coordinator-port.test.ts b/packages/next-plugin/src/__tests__/coordinator-port.test.ts index 421ba1d17..08f51c22a 100644 --- a/packages/next-plugin/src/__tests__/coordinator-port.test.ts +++ b/packages/next-plugin/src/__tests__/coordinator-port.test.ts @@ -71,6 +71,12 @@ describe('coordinator port file', () => { expect(Bun.file(file).size).toBe(0) }) + it('allows first-run cleanup when no coordinator port file exists', () => { + const file = join(tmp(), 'coordinator.port') + + expect(() => removeStalePortFile(file)).not.toThrow() + }) + it('names port, owner pid, state and recovery when the coordinator is unreachable', () => { const dir = tmp() const file = join(dir, 'coordinator.port') diff --git a/packages/next-plugin/src/__tests__/wasm.test.ts b/packages/next-plugin/src/__tests__/wasm.test.ts index d3cc7068c..1b0e3bae5 100644 --- a/packages/next-plugin/src/__tests__/wasm.test.ts +++ b/packages/next-plugin/src/__tests__/wasm.test.ts @@ -132,6 +132,20 @@ describe('WASM loading', () => { ).toEqual({ isolated: true }) }) + it('resolves from the project manifest without an installed or workspace plugin', () => { + const root = mkdtempSync(join(tmpdir(), 'devup-ui-next-root-')) + tempRoots.push(root) + writeFileSync(join(root, 'package.json'), '{"name":"root-fallback"}') + writeFileSync( + join(root, 'dependency.cjs'), + 'module.exports = { root: true }', + ) + + expect( + requireFromPlugin<{ root: boolean }>('./dependency.cjs', root), + ).toEqual({ root: true }) + }) + it('loads or injects the Webpack plugin without a static dependency', () => { expect(typeof loadWebpackPlugin().DevupUIWebpackPlugin).toBe('function') setWebpackPluginForTesting(webpackPlugin) diff --git a/packages/plugin-utils/src/__tests__/reservation-discovery.test.ts b/packages/plugin-utils/src/__tests__/reservation-discovery.test.ts index 757f2b0b9..118651b87 100644 --- a/packages/plugin-utils/src/__tests__/reservation-discovery.test.ts +++ b/packages/plugin-utils/src/__tests__/reservation-discovery.test.ts @@ -169,6 +169,43 @@ it('overlays owned compiled MDX records without reading raw MDX or deduplicating ).toBe(false) }) +it('uses an explicit relative tsconfig instead of the default alias target', () => { + using fs = fixture() + fs.write( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@token': ['wrong.ts'] } }, + }), + ) + fs.write( + 'tsconfig.build.json', + JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@token': ['right.ts'] } }, + }), + ) + fs.write( + 'src/A.tsx', + "import {Box} from '@devup-ui/react'; import {n} from '@token'; export const A=()=> ", + ) + fs.write('wrong.ts', 'export const n=1') + fs.write('right.ts', 'export const n=4') + + const inventory = collectReservationInputs({ + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: {}, + tsconfigPath: 'tsconfig.build.json', + toId: (path) => relative(fs.root, path).replaceAll('\\', '/'), + }) + + expect(inventory.inputs.map(({ filename }) => filename)).toEqual([ + 'right.ts', + 'src/A.tsx', + ]) + expect(inventory.inputs[0].code).toBe('export const n=4') +}) + it('rejects conflicting prepared bytes for one physical path before producing an inventory', () => { // Given using fs = fixture() diff --git a/packages/plugin-utils/src/__tests__/reservation-errors.test.ts b/packages/plugin-utils/src/__tests__/reservation-errors.test.ts index 55e65dd31..f575a1e1a 100644 --- a/packages/plugin-utils/src/__tests__/reservation-errors.test.ts +++ b/packages/plugin-utils/src/__tests__/reservation-errors.test.ts @@ -3,7 +3,7 @@ import { join, relative } from 'node:path' import { expect, it } from 'bun:test' -import { createModuleResolver } from '../import-graph' +import { createModuleResolver, listSourceFiles } from '../import-graph' import { collectReservationInputs } from '../reservation-discovery' import { isReservationReadFailure, @@ -112,6 +112,33 @@ it('continues valid directories when an earlier source root changes from directo ]) }) +it('propagates a source root directory-read failure without an error handler', () => { + using fs = fixture() + fs.write('file-root', 'not a directory') + + expect(() => listSourceFiles(join(fs.root, 'file-root'))).toThrow( + expect.objectContaining({ code: 'ENOTDIR' }), + ) +}) + +it('rethrows the original directory-read error when its handler declines it', () => { + using fs = fixture() + fs.write('file-root', 'not a directory') + let received: unknown + let thrown: unknown + + try { + listSourceFiles(join(fs.root, 'file-root'), (error) => { + received = error + return false + }) + } catch (error) { + thrown = error + } + expect(thrown).toMatchObject({ code: 'ENOTDIR' }) + expect(thrown).toBe(received) +}) + it('leaves the normal resolver as publisher of a reachable broken manifest error', () => { // Given using fs = fixture() diff --git a/packages/react/src/stores/__tests__/theme-store.test.ts b/packages/react/src/stores/__tests__/theme-store.test.ts index 1544aa265..c3c30d7de 100644 --- a/packages/react/src/stores/__tests__/theme-store.test.ts +++ b/packages/react/src/stores/__tests__/theme-store.test.ts @@ -11,6 +11,48 @@ type ThemeValue = Parameters[0] const darkTheme = 'dark' as unknown as ThemeValue describe('themeStore ssr', () => { + it('should select the inert server store when window is undefined', async () => { + // Given: evaluate the factory without a browser window, preserving its descriptor. + const originalWindowDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'window', + ) + + try { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: undefined, + }) + + // When: a fresh import selects the factory in this process. + const { createThemeStore } = await import('../theme-store?ssr-factory') + + // Then: the real server factory provides an inert subscription lifecycle. + expect(createThemeStore).toBe(createServerThemeStore) + const themeStore = createThemeStore() + const notifications: ThemeValue[] = [] + expect(themeStore.get()).toBeNull() + const unsubscribe = themeStore.subscribe((theme) => { + notifications.push(theme) + }) + expect(notifications).toEqual([]) + expect(themeStore.set(darkTheme)).toBeUndefined() + expect(themeStore.get()).toBeNull() + expect(notifications).toEqual([]) + expect(unsubscribe()).toBeUndefined() + expect(unsubscribe()).toBeUndefined() + expect(themeStore.set(darkTheme)).toBeUndefined() + expect(themeStore.get()).toBeNull() + expect(notifications).toEqual([]) + } finally { + if (originalWindowDescriptor) { + Object.defineProperty(globalThis, 'window', originalWindowDescriptor) + } else { + Reflect.deleteProperty(globalThis, 'window') + } + } + }) + it('should filter mutations by type and target', async () => { // const { createThemeStore } = await import('../theme-store') const themeStore = createServerThemeStore() diff --git a/packages/webpack-plugin/src/__tests__/config-errors.test.ts b/packages/webpack-plugin/src/__tests__/config-errors.test.ts new file mode 100644 index 000000000..266a4dbc5 --- /dev/null +++ b/packages/webpack-plugin/src/__tests__/config-errors.test.ts @@ -0,0 +1,40 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { expect, it, spyOn } from 'bun:test' + +import { DevupUIWebpackPlugin } from '../plugin' + +it('clears the theme and creates the CSS directory when configuration cannot be read', () => { + const root = mkdtempSync(join(tmpdir(), 'devup-webpack-config-')) + const config = join(root, 'devup.json') + const cssDir = join(root, 'dist/css') + mkdirSync(config) + wasm.registerTheme({ colors: { default: { brand: '#123456' } } }) + expect(wasm.getDefaultTheme()).toBe('default') + const registered = spyOn(wasm, 'registerTheme') + const logged = spyOn(console, 'error').mockReturnValue(undefined) + try { + const plugin = new DevupUIWebpackPlugin({ + devupFile: config, + distDir: join(root, 'dist'), + cssDir, + watch: false, + }) + + plugin.writeDataFiles() + + expect(registered).toHaveBeenCalledTimes(1) + expect(registered).toHaveBeenCalledWith({}) + expect(wasm.getDefaultTheme()).toBeUndefined() + expect(logged).toHaveBeenCalledTimes(1) + expect(logged).toHaveBeenCalledWith(expect.any(Error)) + expect(existsSync(cssDir)).toBe(true) + } finally { + registered.mockRestore() + logged.mockRestore() + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/packages/webpack-plugin/src/__tests__/plugin.test.ts b/packages/webpack-plugin/src/__tests__/plugin.test.ts index a032e8f6f..3507e4c2e 100644 --- a/packages/webpack-plugin/src/__tests__/plugin.test.ts +++ b/packages/webpack-plugin/src/__tests__/plugin.test.ts @@ -222,19 +222,25 @@ describe('devupUIWebpackPlugin', () => { } }) }) - it('W66 default regressions: should apply default options', () => { - expect(new DevupUIWebpackPlugin({}).options).toEqual({ - include: [], - package: '@devup-ui/react', - cssDir: resolve('df', 'devup-ui'), - devupFile: 'devup.json', - distDir: 'df', - watch: false, - debug: false, - singleCss: false, - atomHoist: 2, - }) - }) + it.each([true, false])( + 'W66 default regressions: should apply default options (omitted: %s)', + (omitted) => { + const plugin = omitted + ? new DevupUIWebpackPlugin() + : new DevupUIWebpackPlugin({}) + expect(plugin.options).toEqual({ + include: [], + package: '@devup-ui/react', + cssDir: resolve('df', 'devup-ui'), + devupFile: 'devup.json', + distDir: 'df', + watch: false, + debug: false, + singleCss: false, + atomHoist: 2, + }) + }, + ) it('also reserves at native watchRun when watch persistence is disabled', async () => { const manager = { From c1295d884cbb61c10d470b4dad57c2cb35d521cf Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 23 Oct 2026 00:39:10 +0900 Subject: [PATCH 2/4] fix(eslint-plugin): preserve sparse responsive values in autofixes Refs #683 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_bun_latest_coverage.json | 2 +- .../__tests__/index.test.ts | 23 +++++++++++++++++-- .../rules/no-useless-tailing-nulls/index.ts | 20 ++++++++-------- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/.changepacks/changepack_log_bun_latest_coverage.json b/.changepacks/changepack_log_bun_latest_coverage.json index 8989e4049..da378968b 100644 --- a/.changepacks/changepack_log_bun_latest_coverage.json +++ b/.changepacks/changepack_log_bun_latest_coverage.json @@ -2,6 +2,6 @@ "changes": { "packages/eslint-plugin/package.json": "Patch" }, - "note": "Preserve trailing-null lint fixes while removing an unreachable AST guard; restore complete behavior coverage on the latest Bun without exclusions or new build errors.", + "note": "Fix sparse trailing-null autofixes to preserve authored values and earlier holes while removing the maximal trailing run of nulls and holes only when the last element is literal null; remove an unreachable AST guard and restore complete behavior coverage on the latest Bun without exclusions or new build errors.", "date": "2026-10-22T00:00:00.000Z" } diff --git a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts index 90decccc7..289f86521 100644 --- a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts @@ -17,7 +17,16 @@ describe('no-useless-tailing-nulls rule', () => { ruleTester.run('no-useless-tailing-nulls rule', noUselessTailingNulls, { valid: [ // Real parser holes are absent elements, not Literal null nodes. - ...['[]', '[,]', '[,null]', '[null,,]', '[1, null, 2]'].map((value) => ({ + ...[ + '[]', + '[,]', + '[1,null,,]', + '[null,,]', + '[,,]', + '[1,,]', + '[1,,2]', + '[1, null, 2]', + ].map((value) => ({ code: `import { css } from "@devup-ui/react";\ncss({w: ${value}})`, filename: 'src/app/page.tsx', })), @@ -53,7 +62,17 @@ describe('no-useless-tailing-nulls rule', () => { ['[null]', '[]'], ['[null,null]', '[]'], ['[1,null]', '[1]'], - ['[1,,null]', '[]'], + ['[1,,null]', '[1]'], + ['[,1,null]', '[,1]'], + ['[1,,2,null]', '[1,,2]'], + ['[1,,null,null]', '[1]'], + ['[,null]', '[]'], + ['[,,null,null]', '[]'], + ['[/* keep */ null,null]', '[/* keep */ ]'], + ['[1,null,,null]', '[1]'], + ['[null,1,,null]', '[null,1]'], + ['[value,,null]', '[value]'], + ['[...values,,null]', '[...values]'], ].map(([value, fixed]) => ({ code: `import { css } from "@devup-ui/react";\ncss({w: ${value}})`, output: `import { css } from "@devup-ui/react";\ncss({w: ${fixed}})`, 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 d04e82840..bc7041423 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 @@ -23,22 +23,24 @@ function checkUselessTailingNulls>( lastElement.value !== null ) return - let nullCount = 1 + let trailingCount = 1 for (let i = node.elements.length - 2; i >= 0; i--) { const element = node.elements[i] - if (element?.type === AST_NODE_TYPES.Literal && element.value === null) { - nullCount++ + if ( + !element || + (element.type === AST_NODE_TYPES.Literal && element.value === null) + ) { + trailingCount++ } else { break } } - const previousElement = node.elements[node.elements.length - nullCount - 1] + const previousElement = + node.elements[node.elements.length - trailingCount - 1] const firstElement = node.elements[0] - const removeStart = - node.elements.length > nullCount && previousElement - ? previousElement.range[1] - : firstElement?.range[0] - if (removeStart === undefined) return + const removeStart = previousElement + ? previousElement.range[1] + : (firstElement?.range[0] ?? node.range[0] + 1) context.report({ node, From 968f0d362c7c3683713c7144bdf338b614e9b316 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 23 Oct 2026 14:57:33 +0900 Subject: [PATCH 3/4] fix(eslint-plugin): preserve authored syntax in trailing-null fixes Refs #683 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_bun_latest_coverage.json | 2 +- .../__tests__/index.test.ts | 105 +++++++++++++++++- .../rules/no-useless-tailing-nulls/index.ts | 43 ++++--- 3 files changed, 133 insertions(+), 17 deletions(-) diff --git a/.changepacks/changepack_log_bun_latest_coverage.json b/.changepacks/changepack_log_bun_latest_coverage.json index da378968b..aca3b06ca 100644 --- a/.changepacks/changepack_log_bun_latest_coverage.json +++ b/.changepacks/changepack_log_bun_latest_coverage.json @@ -2,6 +2,6 @@ "changes": { "packages/eslint-plugin/package.json": "Patch" }, - "note": "Fix sparse trailing-null autofixes to preserve authored values and earlier holes while removing the maximal trailing run of nulls and holes only when the last element is literal null; remove an unreachable AST guard and restore complete behavior coverage on the latest Bun without exclusions or new build errors.", + "note": "Fix sparse trailing-null autofixes to preserve authored values, complete parenthesized syntax and earlier holes using token-based edit boundaries while removing the maximal trailing run of nulls and holes only when the last element is canonical literal null; preserve regular-expression and BigInt literals without validating regex syntax, preserve existing comment and trailing-comma handling, remove an unreachable AST guard and restore complete behavior coverage on the latest Bun without exclusions or new build errors.", "date": "2026-10-22T00:00:00.000Z" } diff --git a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts index 289f86521..cf90a4853 100644 --- a/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts +++ b/packages/eslint-plugin/src/rules/no-useless-tailing-nulls/__tests__/index.test.ts @@ -1,9 +1,110 @@ import { RuleTester } from '@typescript-eslint/rule-tester' -import { describe } from 'bun:test' +import { TSESLint } from '@typescript-eslint/utils' +import { describe, expect, it } from 'bun:test' +import { parser } from 'typescript-eslint' import { noUselessTailingNulls } from '../index' describe('no-useless-tailing-nulls rule', () => { + const boundaryCases = [ + ['[0,(1),,null]', '[0,(1)]'], + ['[0,((1)),,null]', '[0,((1))]'], + ['[0,(1),null]', '[0,(1)]'], + ['[0,(1),,(null)]', '[0,(1)]'], + ['[(null)]', '[]'], + ['[1,null]', '[1]'], + ['[0,((1)),,((null))]', '[0,((1))]'], + ['[((null)),(null)]', '[]'], + [ + '[0,( /* value */ 1 /* inner */ ), /* remove */ , (null)]', + '[0,( /* value */ 1 /* inner */ )]', + ], + ['[1 /* remove */, /* remove */ null /* keep */]', '[1 /* keep */]'], + ['[/* keep */ (null),null]', '[/* keep */ ]'], + ['[, /* remove */ (null)]', '[]'], + ['[0,(1),null,]', '[0,(1),]'], + ['[0,((1)),,((null)),]', '[0,((1)),]'], + ['[(null),]', '[,]'], + ['[null,]', '[,]'], + ['[1,null, /* keep */]', '[1, /* keep */]'], + ['[1,null /* keep */,]', '[1 /* keep */,]'], + [ + '[0,(1), /* remove */ null /* keep */, /* tail */]', + '[0,(1) /* keep */, /* tail */]', + ], + ['[/* keep */ ((null)) /* tail */]', '[/* keep */ /* tail */]'], + ] as const + const literalKindCases = [ + ['[/(/,null]', '[/(/]'], + ['[/a/gg,null]', '[/a/gg]'], + ['[/a/z,null]', '[/a/z]'], + ['[/a/gg,,null]', '[/a/gg]'], + ['[/a/g,null]', '[/a/g]'], + ['[/a/g,,null]', '[/a/g]'], + ['[(/(/),,(null)]', '[(/(/)]'], + ['[1n,null]', '[1n]'], + ['[0xFFn,,null]', '[0xFFn]'], + ['[1n,null,,(null),]', '[1n,]'], + ] as const + const literalKindControls = [ + '[/(/]', + '[/a/gg]', + '[/a/z]', + '[1,null,/(/]', + '[/a/g]', + '[1n]', + '[0xFFn]', + '[null,1n]', + ] as const + const literalKindValidTests = literalKindControls.flatMap((value) => + [false, true].map((jsx) => ({ + code: `import {css,Box} from '@devup-ui/react';\n${jsx ? `` : `css({w:${value}})`}`, + filename: 'src/app/page.tsx', + })), + ) + const boundaryTests = [...boundaryCases, ...literalKindCases].flatMap( + ([value, fixed]) => + [false, true].map((jsx) => ({ + code: `import {css,Box} from '@devup-ui/react';\n${jsx ? `` : `css({w:${value}})`}`, + output: `import {css,Box} from '@devup-ui/react';\n${jsx ? `` : `css({w:${fixed}})`}`, + filename: 'src/app/page.tsx', + errors: [{ messageId: 'uselessTailingNulls' as const }], + })), + ) + it.each([ + ...boundaryTests, + ...literalKindValidTests.map((test) => ({ ...test, output: test.code })), + ])( + 'preserves authored syntax and parses when fixing $code', + ({ code, output }) => { + const linter = new TSESLint.Linter() + const config = { + files: ['**/*.tsx'], + languageOptions: { + parser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + plugins: { devup: { rules: { trailing: noUselessTailingNulls } } }, + rules: { 'devup/trailing': 'error' }, + } satisfies TSESLint.FlatConfig.Config + + const result = linter.verifyAndFix(code, config, { + filename: 'src/app/page.tsx', + }) + + expect(result.output).toBe(output) + expect( + linter.verify( + result.output, + { ...config, rules: {} }, + { + filename: 'src/app/page.tsx', + }, + ), + ).toEqual([]) + expect(result.messages).toEqual([]) + }, + ) const ruleTester = new RuleTester({ languageOptions: { ecmaVersion: 'latest', @@ -16,6 +117,7 @@ describe('no-useless-tailing-nulls rule', () => { }) ruleTester.run('no-useless-tailing-nulls rule', noUselessTailingNulls, { valid: [ + ...literalKindValidTests, // Real parser holes are absent elements, not Literal null nodes. ...[ '[]', @@ -58,6 +160,7 @@ describe('no-useless-tailing-nulls rule', () => { })), ], invalid: [ + ...boundaryTests, ...[ ['[null]', '[]'], ['[null,null]', '[]'], 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 bc7041423..d16f185fb 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 @@ -1,5 +1,6 @@ import { AST_NODE_TYPES, + ASTUtils, ESLintUtils, type TSESTree, } from '@typescript-eslint/utils' @@ -13,23 +14,22 @@ const createRule = ESLintUtils.RuleCreator( `https://github.com/dev-five-git/devup-ui/tree/main/packages/eslint-plugin/src/rules/${name}`, ) +function isNullLiteral( + node: TSESTree.Node | null | undefined, +): node is TSESTree.NullLiteral { + return node?.type === AST_NODE_TYPES.Literal && node.raw === 'null' +} + function checkUselessTailingNulls>( node: TSESTree.ArrayExpression, context: T, ) { const lastElement = node.elements[node.elements.length - 1] - if ( - lastElement?.type !== AST_NODE_TYPES.Literal || - lastElement.value !== null - ) - return + if (!isNullLiteral(lastElement)) return let trailingCount = 1 for (let i = node.elements.length - 2; i >= 0; i--) { const element = node.elements[i] - if ( - !element || - (element.type === AST_NODE_TYPES.Literal && element.value === null) - ) { + if (!element || isNullLiteral(element)) { trailingCount++ } else { break @@ -37,16 +37,29 @@ function checkUselessTailingNulls>( } const previousElement = node.elements[node.elements.length - trailingCount - 1] - const firstElement = node.elements[0] - const removeStart = previousElement - ? previousElement.range[1] - : (firstElement?.range[0] ?? node.range[0] + 1) - context.report({ node, messageId: 'uselessTailingNulls', fix(fixer) { - return fixer.removeRange([removeStart, lastElement.range[1]]) + const sourceCode = context.sourceCode + let removeStart = node.range[0] + 1 + if (previousElement) { + const [separator] = sourceCode.getFirstTokensBetween( + previousElement, + lastElement, + { count: 1, filter: ASTUtils.isCommaToken }, + ) + const [previousToken] = sourceCode.getTokensBefore(separator, 1) + removeStart = previousToken.range[1] + } else if (node.elements[0]) { + const [, firstToken] = sourceCode.getFirstTokens(node, 2) + removeStart = firstToken.range[0] + } + const [beforeLastToken, lastToken] = sourceCode.getLastTokens(node, 3) + const removeEnd = ASTUtils.isCommaToken(lastToken) + ? beforeLastToken.range[1] + : lastToken.range[1] + return fixer.removeRange([removeStart, removeEnd]) }, }) } From 36da206af5746d819c0acf51ac8403d6e9f97f91 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 23 Oct 2026 20:08:11 +0900 Subject: [PATCH 4/4] fix(extractor): compile factory dynamic utility values Refs #791 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...hangepack_log_factory_call_references.json | 7 + .../extract_style_from_expression.rs | 67 ++++-- .../extract_style_from_member_expression.rs | 80 +++---- .../factory_dynamic_value_controls_tests.rs | 199 ++++++++++++++++++ .../factory_dynamic_value_keyframes_tests.rs | 97 +++++++++ .../src/factory_dynamic_value_tests.rs | 168 +++++++++++++++ libs/extractor/src/lib.rs | 9 + libs/extractor/src/visit.rs | 11 +- 8 files changed, 584 insertions(+), 54 deletions(-) create mode 100644 .changepacks/changepack_log_factory_call_references.json create mode 100644 libs/extractor/src/factory_dynamic_value_controls_tests.rs create mode 100644 libs/extractor/src/factory_dynamic_value_keyframes_tests.rs create mode 100644 libs/extractor/src/factory_dynamic_value_tests.rs diff --git a/.changepacks/changepack_log_factory_call_references.json b/.changepacks/changepack_log_factory_call_references.json new file mode 100644 index 000000000..37be6cd37 --- /dev/null +++ b/.changepacks/changepack_log_factory_call_references.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Compile imported style utility calls before serializing dynamic JSX factory values, preserving lexical shadows and deterministic CSS-variable output. Existing located css/keyframes errors now also reject unreadable nested factory values instead of emitting missing runtime calls; no new error kind.", + "date": "2026-10-23T00: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 adc219c45..fe1af3863 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -236,6 +236,7 @@ pub fn extract_style_from_expression<'a>( level, selector, literal_handling, + &mut |_| {}, ) } @@ -247,6 +248,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( level: u8, selector: &Option, literal_handling: LiteralHandling, + prepare: &mut impl FnMut(&mut Expression<'a>), ) -> ExtractResult<'a> { let mut typo = false; let expression = unwrap_syntax_only_mut(expression); @@ -294,7 +296,8 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( { // The upfront authored walk already checks every root key, // including the literal spreads flattened above. - for disassembled in disassemble_property(&name) { + let mut properties = disassemble_property(&name); + while let Some(disassembled) = properties.next() { let disassembled: &str = &disassembled; if name == "styleOrder" { style_order = get_number_by_literal_expression(&prop.value) @@ -310,15 +313,20 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( ast_builder.allocator(), )); } else { + let mut sibling = (properties.len() != 0).then(|| { + prop.value + .clone_in_with_semantic_ids(ast_builder.allocator()) + }); let ExtractResult { styles, tag: _tag, .. } = extract_with_policy::( ast_builder, Some(disassembled), - &mut prop.value, + sibling.as_mut().unwrap_or(&mut prop.value), 0, &None, LiteralHandling::ExpandResponsiveThemeToken, + prepare, ); props_styles.extend(styles); tag = _tag.or(tag); @@ -339,6 +347,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( 0, &None, LiteralHandling::ExpandResponsiveThemeToken, + prepare, ); props_styles.extend(styles); tag = _tag.or(tag); @@ -368,6 +377,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( level, &None, literal_handling, + prepare, ) .styles, ))), @@ -379,6 +389,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( level, selector, literal_handling, + prepare, ) .styles, ))), @@ -413,12 +424,12 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( for part in split_selector_list(name) { styles.extend(extract_under_key::( ast_builder, - part, - offset, - &mut expression.clone_in(ast_builder.allocator()), + (part, offset), + &mut expression.clone_in_with_semantic_ids(ast_builder.allocator()), level, selector, literal_handling, + prepare, )); } return ExtractResult { @@ -458,12 +469,12 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( for part in split_selector_list(key_name.trim()) { props.extend(extract_under_key::( ast_builder, - part, - offset, - &mut o.value.clone_in(ast_builder.allocator()), + (part, offset), + &mut o.value.clone_in_with_semantic_ids(ast_builder.allocator()), level, selector, literal_handling, + prepare, )); } } @@ -490,6 +501,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( level, selector, literal_handling, + prepare, ) .styles; place_in_layer(&mut styles, &layer); @@ -520,6 +532,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( level, &Some(at_selector), literal_handling, + prepare, ) .styles, ); @@ -536,12 +549,12 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( return ExtractResult { styles: extract_under_key::( ast_builder, - name, - offset, + (name, offset), expression, level, selector, literal_handling, + prepare, ), ..ExtractResult::default() }; @@ -655,13 +668,14 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( expression, level, selector, + prepare, )], ..ExtractResult::default() }, None => unreadable(expression), }, Expression::ComputedMemberExpression(mem) => { - extract_member::(ast_builder, name, mem, level, selector) + extract_member::(ast_builder, name, mem, level, selector, prepare) } Expression::TemplateLiteral(_) => { let Some(name) = name else { @@ -715,6 +729,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( expression, level, selector, + prepare, )] }, ..ExtractResult::default() @@ -794,6 +809,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( expression, level, selector, + prepare, )], ..ExtractResult::default() } @@ -822,6 +838,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( level, selector, literal_handling, + prepare, ) } Expression::ConditionalExpression(conditional) @@ -839,6 +856,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( level, selector, literal_handling, + prepare, ) } Expression::LogicalExpression(logical) => { @@ -850,6 +868,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( level, selector, literal_handling, + prepare, ) .styles, ))); @@ -899,6 +918,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( level, selector, literal_handling, + prepare, ) .styles, ))), @@ -931,6 +951,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( idx as u8, selector, LiteralHandling::KeepSingleClass, + prepare, ) .styles, ); @@ -953,6 +974,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( level, selector, literal_handling, + prepare, ) } else { ExtractResult { @@ -966,6 +988,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( level, selector, literal_handling, + prepare, ) .styles, ))), @@ -977,6 +1000,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( level, selector, literal_handling, + prepare, ) .styles, ))), @@ -1043,6 +1067,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( level, &selector, literal_handling, + prepare, ) .styles, ); @@ -1063,16 +1088,20 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( props.push(error); continue; } - for name in disassemble_property(&key_name) { + let mut properties = disassemble_property(&key_name); + while let Some(name) = properties.next() { let name: &str = &name; + let mut sibling = (properties.len() != 0) + .then(|| o.value.clone_in_with_semantic_ids(ast_builder.allocator())); props.extend( extract_with_policy::( ast_builder, Some(name), - &mut o.value, + sibling.as_mut().unwrap_or(&mut o.value), level, &selector, literal_handling, + prepare, ) .styles, ); @@ -1099,6 +1128,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( // A sequence keeps its parentheses, as its code is written into // the element's style object, where a comma ends the property Some(name) if matches!(expression, Expression::SequenceExpression(_)) => { + prepare(expression); let code = expression_to_code(expression); ExtractResult { styles: vec![ExtractStyleProp::Static(ExtractStyleValue::Dynamic( @@ -1121,6 +1151,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( expression, level, selector, + prepare, )], ..ExtractResult::default() }, @@ -1340,13 +1371,14 @@ impl From> for Nested { fn extract_under_key<'a, const LOCATE: bool>( ast_builder: &AstBuilder<'a>, - key: &str, - offset: u32, + located_key: (&str, u32), expression: &mut Expression<'a>, level: u8, selector: &Option, literal_handling: LiteralHandling, + prepare: &mut impl FnMut(&mut Expression<'a>), ) -> Vec> { + let (key, offset) = located_key; match nest_selectors_key(selector.as_ref(), key) { Nested::Under(nested) => { extract_with_policy::( @@ -1356,6 +1388,7 @@ fn extract_under_key<'a, const LOCATE: bool>( level, &Some(nested), literal_handling, + prepare, ) .styles } @@ -1470,9 +1503,10 @@ fn conditional_typography<'a>( pub fn dynamic_style<'a>( ast_builder: &AstBuilder<'a>, name: &str, - expression: &Expression<'a>, + expression: &mut Expression<'a>, level: u8, selector: &Option, + prepare: &mut impl FnMut(&mut Expression<'a>), ) -> ExtractStyleProp<'a> { if let Some(map) = get_enum_property_map(name) { ExtractStyleProp::Enum { @@ -1494,6 +1528,7 @@ pub fn dynamic_style<'a>( .collect(), } } else { + prepare(expression); ExtractStyleProp::Static(ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( name, level, diff --git a/libs/extractor/src/extractor/extract_style_from_member_expression.rs b/libs/extractor/src/extractor/extract_style_from_member_expression.rs index 2e2a2ab41..147c6c26f 100644 --- a/libs/extractor/src/extractor/extract_style_from_member_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_member_expression.rs @@ -26,18 +26,24 @@ pub(super) fn extract_member<'a, const LOCATE: bool>( mem: &mut ComputedMemberExpression<'a>, level: u8, selector: &Option, + prepare: &mut impl FnMut(&mut Expression<'a>), ) -> ExtractResult<'a> { - let mem_expression = &mem.expression.clone_in(ast_builder.allocator()); + let mem_expression = &mem + .expression + .clone_in_with_semantic_ids(ast_builder.allocator()); let mut ret: Vec = vec![]; // Unwrap type assertions and parenthesized expressions (e.g., `({...} as const)[key]`) while let Some(inner) = match &mem.object { - Expression::TSAsExpression(ts_as) => { - Some(ts_as.expression.clone_in(ast_builder.allocator())) - } - Expression::ParenthesizedExpression(p) => { - Some(p.expression.clone_in(ast_builder.allocator())) - } + Expression::TSAsExpression(ts_as) => Some( + ts_as + .expression + .clone_in_with_semantic_ids(ast_builder.allocator()), + ), + Expression::ParenthesizedExpression(p) => Some( + p.expression + .clone_in_with_semantic_ids(ast_builder.allocator()), + ), _ => None, } { mem.object = inner; @@ -56,16 +62,26 @@ pub(super) fn extract_member<'a, const LOCATE: bool>( .any(|property| matches!(property, ObjectPropertyKind::SpreadProperty(_))), _ => false, } - .then(|| mem.object.clone_in(ast_builder.allocator())); - let runtime = |whole: &Expression<'a>, offset: u32| { + .then(|| { + mem.object + .clone_in_with_semantic_ids(ast_builder.allocator()) + }); + let runtime = |whole: &Expression<'a>, offset: u32, prepare: &mut _| { + let mut member = Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( + SPAN, + whole.clone_in_with_semantic_ids(ast_builder.allocator()), + mem_expression.clone_in_with_semantic_ids(ast_builder.allocator()), + false, + ast_builder, + )); runtime_member::( ast_builder, name, - whole.clone_in(ast_builder.allocator()), - mem_expression, + &mut member, offset, level, selector, + prepare, ) }; @@ -92,13 +108,14 @@ pub(super) fn extract_member<'a, const LOCATE: bool>( level, selector, LiteralHandling::ExpandResponsiveThemeToken, + prepare, ); } } return ExtractResult { styles: spread .iter() - .map(|whole| runtime(whole, mem.span.start)) + .map(|whole| runtime(whole, mem.span.start, prepare)) .collect(), ..ExtractResult::default() }; @@ -109,7 +126,10 @@ pub(super) fn extract_member<'a, const LOCATE: bool>( if let ArrayExpressionElement::SpreadElement(sp) = p && let Some(whole) = &spread { - map.insert(idx.to_string(), Box::new(runtime(whole, sp.span.start))); + map.insert( + idx.to_string(), + Box::new(runtime(whole, sp.span.start, prepare)), + ); } else if let Some(p) = p.as_expression_mut() { map.insert( idx.to_string(), @@ -121,6 +141,7 @@ pub(super) fn extract_member<'a, const LOCATE: bool>( level, selector, LiteralHandling::ExpandResponsiveThemeToken, + prepare, ) .styles, )), @@ -129,7 +150,7 @@ pub(super) fn extract_member<'a, const LOCATE: bool>( } ret.push(ExtractStyleProp::MemberExpression { - expression: mem_expression.clone_in(ast_builder.allocator()), + expression: mem_expression.clone_in_with_semantic_ids(ast_builder.allocator()), map, }); } else if let Expression::ObjectExpression(obj) = &mut mem.object @@ -148,7 +169,7 @@ pub(super) fn extract_member<'a, const LOCATE: bool>( .any(|p| matches!(p, ObjectPropertyKind::SpreadProperty(_))); if replaced && let Some(whole) = &spread { return ExtractResult { - styles: vec![runtime(whole, mem.span.start)], + styles: vec![runtime(whole, mem.span.start, prepare)], ..ExtractResult::default() }; } @@ -164,6 +185,7 @@ pub(super) fn extract_member<'a, const LOCATE: bool>( level, selector, LiteralHandling::ExpandResponsiveThemeToken, + prepare, ) .styles, ..ExtractResult::default() @@ -187,6 +209,7 @@ pub(super) fn extract_member<'a, const LOCATE: bool>( level, selector, LiteralHandling::ExpandResponsiveThemeToken, + prepare, ) .styles, )), @@ -194,22 +217,14 @@ pub(super) fn extract_member<'a, const LOCATE: bool>( } } ret.push(ExtractStyleProp::MemberExpression { - expression: mem_expression.clone_in(ast_builder.allocator()), + expression: mem_expression.clone_in_with_semantic_ids(ast_builder.allocator()), map, }); } else if !matches!( &mem.object, Expression::ArrayExpression(_) | Expression::ObjectExpression(_) ) { - ret.push(runtime_member::( - ast_builder, - name, - mem.object.clone_in(ast_builder.allocator()), - mem_expression, - mem.span.start, - level, - selector, - )); + ret.push(runtime(&mem.object, mem.span.start, prepare)); } ExtractResult { @@ -223,24 +238,17 @@ pub(super) fn extract_member<'a, const LOCATE: bool>( fn runtime_member<'a, const LOCATE: bool>( ast_builder: &AstBuilder<'a>, name: Option<&str>, - object: Expression<'a>, - key: &Expression<'a>, + member: &mut Expression<'a>, offset: u32, level: u8, selector: &Option, + prepare: &mut impl FnMut(&mut Expression<'a>), ) -> ExtractStyleProp<'a> { - let member = Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( - SPAN, - object, - key.clone_in(ast_builder.allocator()), - false, - ast_builder, - )); let mut style = match name { - Some(name) => dynamic_style(ast_builder, name, &member, level, selector), + Some(name) => dynamic_style(ast_builder, name, member, level, selector, prepare), None => ExtractStyleProp::Unreadable { offset, - code: readable_code(&member), + code: readable_code(member), prop: false, requirement: None, }, diff --git a/libs/extractor/src/factory_dynamic_value_controls_tests.rs b/libs/extractor/src/factory_dynamic_value_controls_tests.rs new file mode 100644 index 000000000..edd8b2cb2 --- /dev/null +++ b/libs/extractor/src/factory_dynamic_value_controls_tests.rs @@ -0,0 +1,199 @@ +use super::*; + +#[rstest] +#[case::direct("other:OWN")] +#[case::template("other:`${OWN}`")] +#[case::selector("selectors:{ '&:hover':{ other:OWN } }")] +#[case::member("other:(({ ...{},a:OWN }) as const)[key]")] +#[serial] +fn lexical_helpers_stay_local_when_prepared_values_copy_semantic_references( + #[case] props: &str, + #[values(false, true)] namespace: bool, + #[values(false, true)] single_css: bool, +) { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let (parameter, call, argument) = if namespace { + ( + "ui", + "ui.css({overflow:'hidden'})", + "{css:rules=>(trace.push(rules.overflow),'local')}", + ) + } else { + ( + "i", + "i({overflow:'hidden'})", + "rules=>(trace.push(rules.overflow),'local')", + ) + }; + let props = props.replace("OWN", call); + let source = format!( + "{} import {{jsx as o}} from 'react/jsx-runtime'; function render({parameter},trace,key,index){{ return o(t,{{{props}}}); }}", + imports(namespace) + ); + // When + let output = extract_factory(&source, single_css); + // Then: the distinct local value is returned, without own overflow styles. + assert_eq!( + output + .styles + .iter() + .filter(|style| matches!(style, ExtractStyleValue::Static(_))) + .count(), + 0 + ); + let invocation = format!("render({argument},trace,'a',0)"); + let observed = render_observation(&output, &invocation, (!single_css).then_some(FILE)); + let (trace, values): (Vec, Vec) = serde_json::from_str(&observed).unwrap(); + assert_eq!(values.len(), trace.len()); + assert_eq!(values, vec!["local"; trace.len()]); + assert_eq!(trace, ["hidden"]); +} + +#[rstest] +#[case::direct("other:OWN")] +#[case::member("other:(({ ...{},a:OWN }) as const)[key]")] +#[serial] +fn block_local_helpers_run_once_when_factory_preparation_respects_lexical_bindings( + #[case] props: &str, + #[values(false, true)] namespace: bool, + #[values(false, true)] single_css: bool, +) { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let helper = "rules=>(trace.push('block:'+rules.overflow),'block-local')"; + let (declaration, call) = if namespace { + ( + format!("const ui={{css:{helper}}};"), + "ui.css({overflow:'hidden'})", + ) + } else { + (format!("const i={helper};"), "i({overflow:'hidden'})") + }; + let props = props.replace("OWN", call); + let source = format!( + "{} import {{jsx as o}} from 'react/jsx-runtime'; function render(trace,key){{ {{ {declaration} return o(t,{{{props}}}); }} }}", + imports(namespace) + ); + // When + let output = extract_factory(&source, single_css); + // Then + assert_eq!( + output + .styles + .iter() + .filter(|style| matches!(style, ExtractStyleValue::Static(_))) + .count(), + 0 + ); + assert_eq!( + render_observation(&output, "render(trace,'a')", (!single_css).then_some(FILE)), + "[[\"block:hidden\"],[\"block-local\"]]" + ); +} + +#[rstest] +#[case::false_value(("false", "false"))] +#[case::null_value(("null", "null"))] +#[case::undefined_value(("undefined", "undefined"))] +#[serial] +fn ordinary_helper_preserves_absence_when_dynamic_value_is_serialized( + #[case] sample: (&str, &str), + #[values(false, true)] single_css: bool, +) { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let (value, expected) = sample; + let source = "import {Box as t} from '@devup-ui/react'; import {jsx as o} from 'react/jsx-runtime'; function render(value,trace){const held=x=>(trace.push('held'),x); return o(t,{other:held(value)});}"; + // When + let output = extract_factory(source, single_css); + // Then: compare the actual held value, distinguishing undefined from null. + let invocation = format!( + "(() => {{const result=render({value},trace); trace.push(String(Object.values(result.props.style)[0])); return {{props:{{}}}};}})()" + ); + assert_eq!( + render_observation(&output, &invocation, (!single_css).then_some(FILE)), + format!("[[\"held\",{expected:?}],[]]") + ); +} + +#[rstest] +#[serial] +fn nested_helper_keeps_lazy_branch_and_order_when_preparing_template( + #[values(false, true)] single_css: bool, + #[values(false, true)] h: bool, +) { + // Given: laziness lives inside the retained template, not a queued eager prop branch. + let _state = crate::test_state::TestStateGuard::new(); + let source = "import {Box as t,css as i} from '@devup-ui/react'; import {jsx as o} from 'react/jsx-runtime'; function render(h,trace){const held=x=>(trace.push('held'),x); return o(t,{other:`${h?held(i({overflow:'hidden'})):''}${(trace.push('tail'),'')}`});}"; + // When + let output = extract_factory(source, single_css); + // Then + assert_eq!( + held_observation(&output, &format!("render({h},trace)"), single_css), + if h { + "[[\"held\",\"tail\"],[\"hidden\"]]" + } else { + "[[\"tail\"],[]]" + } + ); +} + +#[rstest] +#[serial] +fn conditional_arms_select_real_css_when_each_holds_a_compiled_call( + #[values(false, true)] single_css: bool, + #[values(false, true)] h: bool, +) { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let source = "import {Box as t,css as i} from '@devup-ui/react'; import {jsx as o} from 'react/jsx-runtime'; function render(h){return o(t,{other:h?i({overflow:'hidden'}):i({overflow:'scroll'})});}"; + // When + let output = extract_factory(source, single_css); + // Then + assert_eq!( + held_observation(&output, &format!("render({h})"), single_css), + if h { + "[[],[\"hidden\"]]" + } else { + "[[],[\"scroll\"]]" + } + ); +} + +#[rstest] +#[serial] +fn names_repeat_after_reset_when_multiple_imports_and_local_collisions_coexist( + #[values(false, true)] namespace: bool, + #[values(false, true)] single_css: bool, +) { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let call = if namespace { "ui.css" } else { "i" }; + let source = format!( + "{} import {{css as j}} from '@devup-ui/react'; import {{jsx as o}} from 'react/jsx-runtime'; function render(trace){{const a='local-a',ui$css='local-ui'; return o(t,{{other:`${{{call}({{overflow:'hidden'}})}} ${{j({{overflow:'scroll'}})}} ${{{call}({{overflow:'hidden'}})}}`}});}}", + imports(namespace) + ); + // When + let outputs: Vec<_> = (0..2) + .map(|_| { + let _state = crate::test_state::TestStateGuard::new(); + css::set_prefix(Some("held-".into())); + let output = extract_factory(&source, single_css); + let held = held_observation(&output, "render(trace)", single_css); + (output, held) + }) + .collect(); + // Then + assert_eq!(outputs[0].0.code, outputs[1].0.code); + assert_eq!(outputs[0].0.styles, outputs[1].0.styles); + assert_eq!(outputs[0].1, outputs[1].1); + let (trace, mut values): (Vec, Vec) = + serde_json::from_str(&outputs[0].1).unwrap(); + values.sort(); + assert_eq!( + (trace, values), + (vec![], vec!["hidden".into(), "scroll".into()]) + ); + assert!(outputs[0].0.code.contains("held-")); +} diff --git a/libs/extractor/src/factory_dynamic_value_keyframes_tests.rs b/libs/extractor/src/factory_dynamic_value_keyframes_tests.rs new file mode 100644 index 000000000..29457ae4e --- /dev/null +++ b/libs/extractor/src/factory_dynamic_value_keyframes_tests.rs @@ -0,0 +1,97 @@ +use super::*; + +#[rstest] +#[case::direct("OWN")] +#[case::template("`${OWN}`")] +#[case::member("lookup(OWN)['a']")] +#[serial] +fn held_animation_names_link_keyframes_when_factory_prepares_dynamic_values( + #[case] expression: &str, + #[values(false, true)] namespace: bool, + #[values(false, true)] single_css: bool, +) { + // Given: the ordinary class observer intentionally does not accept keyframes. + let _state = crate::test_state::TestStateGuard::new(); + let (import, call) = if namespace { + ( + "import {Box as t} from '@devup-ui/react'; import * as ui from '@devup-ui/react';", + "ui.keyframes", + ) + } else { + ( + "import {Box as t,keyframes as k} from '@devup-ui/react';", + "k", + ) + }; + let expression = expression.replace( + "OWN", + &format!("{call}({{from:{{opacity:0}},to:{{opacity:1}}}})"), + ); + let source = format!( + "{import} import {{jsx as o}} from 'react/jsx-runtime'; function render(){{const lookup=x=>({{a:x}}); return o(t,{{animationName:{expression}}});}}" + ); + // When + let output = extract_factory(&source, single_css); + // Then: execute the emitted value without installing any keyframes callable. + let arena = Allocator::default(); + let parsed = Parser::new(&arena, &output.code, SourceType::mjs()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let mut program = parsed.program; + program + .body + .retain(|statement| !matches!(statement, Statement::ImportDeclaration(_))); + let script = format!( + "const o=(target,props)=>({{target,props}}); {} JSON.stringify(Object.values(render().props.style));", + Codegen::new().build(&program).code + ); + let observed = Context::default() + .eval(Source::from_bytes(&script)) + .unwrap_or_else(|error| panic!("{error}\n{script}")); + let values: Vec = + serde_json::from_str(&observed.as_string().unwrap().to_std_string_escaped()).unwrap(); + assert_eq!(values.len(), 1); + let keyframes: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Keyframes(frames) => Some((style, frames)), + _ => None, + }) + .collect(); + assert_eq!(keyframes.len(), 1); + let Some(StyleProperty::ClassName(name)) = + keyframes[0].0.extract((!single_css).then_some(FILE)) + else { + panic!("missing keyframes name") + }; + assert_eq!(values, [name]); + let steps: Vec<_> = keyframes[0] + .1 + .keyframes + .iter() + .map(|(step, styles)| { + ( + step.as_str(), + styles + .iter() + .map(|style| (style.property(), style.value())) + .collect::>(), + ) + }) + .collect(); + assert_eq!( + steps, + [ + ("from", vec![("opacity", "0")]), + ("to", vec![("opacity", "1")]) + ] + ); + assert_eq!( + output + .styles + .iter() + .filter(|style| matches!(style, ExtractStyleValue::Dynamic(_))) + .count(), + 1 + ); +} diff --git a/libs/extractor/src/factory_dynamic_value_tests.rs b/libs/extractor/src/factory_dynamic_value_tests.rs new file mode 100644 index 000000000..0aa910f9e --- /dev/null +++ b/libs/extractor/src/factory_dynamic_value_tests.rs @@ -0,0 +1,168 @@ +use super::*; + +const FILE: &str = "/src/factory-dynamic.tsx"; + +fn imports(namespace: bool) -> &'static str { + if namespace { + "import {Box as t} from '@devup-ui/react'; import * as ui from '@devup-ui/react';" + } else { + "import {Box as t,css as i} from '@devup-ui/react';" + } +} + +fn extract_factory(source: &str, single_css: bool) -> ExtractOutput { + extract( + FILE, + source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{source}\n{error}")) +} + +fn held_observation(output: &ExtractOutput, invocation: &str, single_css: bool) -> String { + let filename = (!single_css).then_some(FILE); + let variables: Vec<(String, String)> = output + .styles + .iter() + .filter_map(|style| match style.extract(filename) { + Some(StyleProperty::Variable { + class_name, + variable_name, + .. + }) => Some((class_name, variable_name)), + _ => None, + }) + .collect(); + let variables = serde_json::to_string(&variables).unwrap(); + let projection = format!( + "(() => {{ const result=({invocation}); const active=new Set((result.props.className || '').split(/\\s+/)); return {{props:{{className:{variables}.filter(([name])=>active.has(name)).map(([,key])=>(result.props.style || {{}})[key]).join(' ')}}}}; }})()" + ); + render_observation(output, &projection, filename) +} + +#[rstest] +#[case::direct(("other: OWN", "[]", 1))] +#[case::spread(("...{ className:'external',other:OWN }", "[]", 1))] +#[case::nested_spread(("...{ ...{ other:OWN } }", "[]", 1))] +#[case::responsive(("other:[OWN,null,OWN]", "[]", 2))] +#[case::hover(("_hover:{ other:OWN }", "[]", 1))] +#[case::selector(("selectors:{ '&:hover,&:focus':{ other:OWN } }", "[]", 2))] +#[case::at_rule(("'@media':{ 'screen':{ other:OWN } }", "[]", 1))] +#[case::layer(("'@layer':{ 'test':{ other:OWN } }", "[]", 1))] +#[case::template(("other:`${OWN}`", "[]", 1))] +#[case::sequence(("other:(trace.push('sequence'),OWN)", "[\"sequence\"]", 1))] +#[case::binary(("other:OWN+''", "[]", 1))] +#[case::helper(("other:held(OWN)", "[\"held\"]", 1))] +#[case::member_fallback(("other:lookup(OWN)[key]", "[\"lookup\"]", 1))] +#[case::member_object(("other:({ a:OWN,b:OWN })[key]", "[]", 1))] +#[case::member_array(("other:([OWN,OWN])[0]", "[]", 1))] +#[case::member_dynamic_array(("other:([OWN,OWN])[index]", "[]", 1))] +#[case::member_spread(("other:({ ...p,a:OWN })[key]", "[]", 1))] +#[case::array_spread(("other:([OWN,...p])[index]", "[]", 2))] +#[case::wrapper(("other:(({ ...p,a:OWN }) as const)[key]", "[]", 1))] +#[case::static_object(("other:({ a:OWN })['a']", "[]", 1))] +#[case::shorthand(("mx:OWN", "[]", 2))] +#[serial] +fn held_css_links_real_styles_when_factory_serializes_dynamic_values( + #[case] sample: (&str, &str, usize), + #[values(false, true)] namespace: bool, + #[values(false, true)] single_css: bool, +) { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let (props, trace, count) = sample; + let call = if namespace { + "ui.css({overflow:'hidden'})" + } else { + "i({overflow:'hidden'})" + }; + let props = props.replace("OWN", call); + let source = format!( + "{} import {{jsx as o}} from 'react/jsx-runtime'; function render(h,p,key,trace,index) {{ const held=x=>(trace.push('held'),x); const lookup=x=>(trace.push('lookup'),{{a:x}}); return o(t,{{{props}}}); }}", + imports(namespace) + ); + // When + let mut output = extract_factory(&source, single_css); + // Then: a prepared literal still occupies the original dynamic variable slot. + let dynamic = output + .styles + .iter() + .filter(|style| matches!(style, ExtractStyleValue::Dynamic(_))) + .count(); + assert_eq!(dynamic, count, "{}", output.code); + if props.starts_with("'@layer'") { + let layers: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::LayerOrder(order) => Some(order.layers.as_slice()), + _ => None, + }) + .collect(); + assert_eq!(layers, [vec!["test".to_string()].as_slice()]); + output + .styles + .retain(|style| !matches!(style, ExtractStyleValue::LayerOrder(_))); + } + assert_eq!( + held_observation(&output, "render(false, [], 'a', trace, 0)", single_css), + format!("[{trace},[\"hidden\"]]") + ); +} + +#[rstest] +#[serial] +fn exact_spread_factory_holds_compiled_css_when_original_reference_rows_execute( + #[values(false, true)] namespace: bool, + #[values(false, true)] single_css: bool, +) { + // Given: the exact original source, filename and invocation, not a relaxed substitute. + let _state = crate::test_state::TestStateGuard::new(); + let (import, call, filename) = if namespace { + ( + "import { Box as t } from '@devup-ui/react'; import * as ui from '@devup-ui/react';", + "ui.css", + "/src/W70h-seam-spread-with-call-namespace.js", + ) + } else { + ( + "import { Box as t, css as i } from '@devup-ui/react';", + "i", + "/src/W70h-seam-spread-with-call-alias.js", + ) + }; + let source = format!( + "{import} import {{ jsx as o }} from 'react/jsx-runtime'; function render(h, p, getProps, clsx) {{ return o(t, {{ ...{{ className: 'external', other: {call}({{ overflow: 'hidden' }}) }} }}); }}" + ); + // When + let output = extract( + filename, + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap(); + // Then + let filename = (!single_css).then_some(filename); + let dynamic: Vec<_> = output + .styles + .iter() + .filter(|style| matches!(style, ExtractStyleValue::Dynamic(_))) + .collect(); + assert_eq!(dynamic.len(), 1); + let Some(StyleProperty::Variable { variable_name, .. }) = dynamic[0].extract(filename) else { + panic!("missing held variable") + }; + let projection = format!( + "(() => {{const result=render(false, {{}}, () => ({{}}), undefined); trace.push(result.target, result.props.className.split(/\\s+/).includes('external')); return {{props:{{className:result.props.style[{variable_name:?}]}}}};}})()" + ); + assert_eq!( + render_observation(&output, &projection, filename), + "[[\"div\",true],[\"hidden\"]]" + ); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 2a068b314..e5aa0f06e 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1044,6 +1044,15 @@ mod tests { mod ci_factory_alias { include!("ci_factory_alias_tests.rs"); } + mod factory_dynamic_value { + include!("factory_dynamic_value_tests.rs"); + mod controls { + include!("factory_dynamic_value_controls_tests.rs"); + } + mod keyframes { + include!("factory_dynamic_value_keyframes_tests.rs"); + } + } use super::*; use crate::extract_style::style_property::StyleProperty; diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 999e6f0a6..06b4e5760 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -920,6 +920,7 @@ impl<'a> DevupVisitor<'a> { 0, &None, LiteralHandling::ExpandResponsiveThemeToken, + &mut |_| {}, ); if let Some(element) = element { let mut unreadable = Vec::new(); @@ -4188,19 +4189,25 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { &self.ast, ); let mut props_styles = vec![]; + let ast = AstBuilder::new(self.ast.allocator()); let ExtractResult { styles, tag: _tag, style_order, style_vars, props, - } = extract_style_from_expression( - &self.ast, + } = extract_with_policy::( + &ast, None, it.arguments[1].to_expression_mut(), 0, &None, LiteralHandling::ExpandResponsiveThemeToken, + &mut |value| match value { + // DevupVisitor leaves bare identifier references unchanged. + Expression::Identifier(_) => {} + _ => self.visit_expression(value), + }, ); props_styles.extend(styles);