From 17c2fb4abf73b65695b6fb2873c9cf490a9da5b8 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Thu, 22 Oct 2026 15:35:26 +0000 Subject: [PATCH 1/7] fix(detectors): don't treat non-tag exports on require bindings as styled tags Co-authored-by: Evan Jacobs --- .changeset/named-import-helper-order.md | 5 +++ .changeset/require-non-tag-exports.md | 5 +++ src/utils/detectors.js | 38 +++++++++++++++++++ .../.babelrc | 15 ++++++++ .../code.js | 7 ++++ .../output.js | 9 +++++ .../fixtures/require-non-tag-exports/.babelrc | 15 ++++++++ test/fixtures/require-non-tag-exports/code.js | 9 +++++ .../require-non-tag-exports/output.js | 14 +++++++ 9 files changed, 117 insertions(+) create mode 100644 .changeset/named-import-helper-order.md create mode 100644 .changeset/require-non-tag-exports.md create mode 100644 test/fixtures/require-non-tag-exports-styled-name/.babelrc create mode 100644 test/fixtures/require-non-tag-exports-styled-name/code.js create mode 100644 test/fixtures/require-non-tag-exports-styled-name/output.js create mode 100644 test/fixtures/require-non-tag-exports/.babelrc create mode 100644 test/fixtures/require-non-tag-exports/code.js create mode 100644 test/fixtures/require-non-tag-exports/output.js diff --git a/.changeset/named-import-helper-order.md b/.changeset/named-import-helper-order.md new file mode 100644 index 00000000..02a1a47e --- /dev/null +++ b/.changeset/named-import-helper-order.md @@ -0,0 +1,5 @@ +--- +'babel-plugin-styled-components': patch +--- + +Helpers imported before `styled` in a named import are recognized now. In `import { css, styled } from 'styled-components'`, `css` and `keyframes` are minified, and with pure annotations turned on they are marked pure. diff --git a/.changeset/require-non-tag-exports.md b/.changeset/require-non-tag-exports.md new file mode 100644 index 00000000..d12e6455 --- /dev/null +++ b/.changeset/require-non-tag-exports.md @@ -0,0 +1,5 @@ +--- +'babel-plugin-styled-components': patch +--- + +CommonJS files that call helpers like `isStyledComponent` on the required module crashed at runtime after the last release. `const sc = require('styled-components')` followed by `sc.isStyledComponent(A)` was rewritten into `sc.isStyledComponent.withConfig(...)(A)`, which throws `TypeError: sc.isStyledComponent.withConfig is not a function` on styled-components v5, v6, and v7. Those helper calls are left alone now, and real tags such as `sc.div` and `sc.default.div` still receive a display name and component id. diff --git a/src/utils/detectors.js b/src/utils/detectors.js index 219edf12..5c8cb7fa 100644 --- a/src/utils/detectors.js +++ b/src/utils/detectors.js @@ -160,6 +160,41 @@ const globalStyledAlias = (state, defaultLocal) => ? 'styled' : null +// Named exports that are never styled tags. A required binding is the module +// object (v5, v6) or the callable `styled` with these attached (v7), so +// `sc.isStyledComponent(x)` must not be decorated. Helpers stay with isHelper. +// Intrinsic tags (div, View, and the rest) are not in this set. +const NON_TAG_EXPORTS = new Set([ + 'NativeStyleContext', + 'ParentContext', + 'SafeAreaInsetsProvider', + 'ServerStyleSheet', + 'StyleSheetConsumer', + 'StyleSheetContext', + 'StyleSheetManager', + 'ThemeConsumer', + 'ThemeContext', + 'ThemeProvider', + '__PRIVATE__', + '__testOnly_verticalAlignCompanions', + 'createTheme', + 'extractCSS', + 'isStyledComponent', + 'matchMedia', + 'setAnimationDebug', + 'styled', + 'stylisPluginRSC', + 'toStyleSheet', + 'useBreakpoint', + 'useContainer', + 'useContainerQuery', + 'useMediaEnv', + 'useMediaQuery', + 'useNativeStyleContext', + 'useParentContext', + 'version', +]) + // `ns.default`: the callable default export reached through a namespace const isNamespaceDefault = (t, node, defaultLocal, state) => t.isMemberExpression(node) && @@ -191,6 +226,9 @@ export const isStyled = t => (tag, state) => { return ( (t.isMemberExpression(tag) && tag.property.name !== 'default' && + !( + t.isIdentifier(tag.property) && NON_TAG_EXPORTS.has(tag.property.name) + ) && matchesDefault(tag.object.name) && !isHelper(t)(tag.property, state)) || (t.isCallExpression(tag) && matchesDefault(tag.callee.name)) || diff --git a/test/fixtures/require-non-tag-exports-styled-name/.babelrc b/test/fixtures/require-non-tag-exports-styled-name/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-non-tag-exports-styled-name/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-non-tag-exports-styled-name/code.js b/test/fixtures/require-non-tag-exports-styled-name/code.js new file mode 100644 index 00000000..1c039669 --- /dev/null +++ b/test/fixtures/require-non-tag-exports-styled-name/code.js @@ -0,0 +1,7 @@ +const styled = require('styled-components') + +const x = () => null +const ok = styled.isStyledComponent(x) +const theme = styled.createTheme({}) +const extracted = styled.extractCSS() +const Tag = styled.div`color: red;` diff --git a/test/fixtures/require-non-tag-exports-styled-name/output.js b/test/fixtures/require-non-tag-exports-styled-name/output.js new file mode 100644 index 00000000..8518e6f3 --- /dev/null +++ b/test/fixtures/require-non-tag-exports-styled-name/output.js @@ -0,0 +1,9 @@ +const styled = require('styled-components'); +const x = () => null; +const ok = styled.isStyledComponent(x); +const theme = styled.createTheme({}); +const extracted = styled.extractCSS(); +const Tag = styled.div.withConfig({ + displayName: "code__Tag", + componentId: "sc-w432we-0" +})(["color: red;"]); diff --git a/test/fixtures/require-non-tag-exports/.babelrc b/test/fixtures/require-non-tag-exports/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-non-tag-exports/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-non-tag-exports/code.js b/test/fixtures/require-non-tag-exports/code.js new file mode 100644 index 00000000..abf7424d --- /dev/null +++ b/test/fixtures/require-non-tag-exports/code.js @@ -0,0 +1,9 @@ +const sc = require('styled-components') + +const A = sc.div`color: red;` +const B = sc.default.div`color: blue;` +const ok = sc.isStyledComponent(A) +const theme = sc.createTheme({}) +const extracted = sc.extractCSS() +const sheet = sc.toStyleSheet() +const factory = sc.styled(A) diff --git a/test/fixtures/require-non-tag-exports/output.js b/test/fixtures/require-non-tag-exports/output.js new file mode 100644 index 00000000..50de1289 --- /dev/null +++ b/test/fixtures/require-non-tag-exports/output.js @@ -0,0 +1,14 @@ +const sc = require('styled-components'); +const A = sc.div.withConfig({ + displayName: "code__A", + componentId: "sc-c84c7x-0" +})(["color: red;"]); +const B = sc.default.div.withConfig({ + displayName: "code__B", + componentId: "sc-c84c7x-1" +})(["color: blue;"]); +const ok = sc.isStyledComponent(A); +const theme = sc.createTheme({}); +const extracted = sc.extractCSS(); +const sheet = sc.toStyleSheet(); +const factory = sc.styled(A); From e37092aba55717a3c381056d370cf786ece9e82b Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Thu, 22 Oct 2026 15:39:54 +0000 Subject: [PATCH 2/7] docs(agents): add review compatibility matrix Co-authored-by: Evan Jacobs --- AGENTS.md | 9 + scripts/compat.js | 458 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 467 insertions(+) create mode 100644 scripts/compat.js diff --git a/AGENTS.md b/AGENTS.md index 4ab3ea1f..735972e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ Commands - `pnpm build`: compile `src/` to `lib/`; run it to verify every set of changes. Don't start a dev server unless asked - `pnpm bench [--base ] [--config default|all|bare] [--rounds N] [--normalize-aliases]`: interleaved A/B of the plugin at `` (default `origin/main`) against the working tree over generated inputs; fails when outputs differ - `pnpm bench:profile [--input small|medium|large|large-cjs]`: CPU profile with the plugin frames that drive Babel's work +- `node scripts/compat.js [--base ]`: styled-components v5, v6, and v7 prerelease smoke against the working tree and `` (default `origin/main`) - `pnpm style`: Prettier over `src/` - `pnpm changeset`: add a release note @@ -86,6 +87,14 @@ Testing - Avoid snapshot churn. A change rewrites only the snapshots its behavior requires, and a hand-written expected output is changed only when the expectation itself was wrong, with the reason in the commit. - Jest `roots` is `test/`, so agent worktrees under `.claude/worktrees/` stay out of the run. +Review + +- Every behavior change is checked against styled-components v5 (latest 5.x), v6 (latest 6.x), and v7 (latest prerelease dist-tag). `node scripts/compat.js` compiles smoke programs with the branch and with main, then executes the output (`renderToString` plus `ServerStyleSheet`) against each version. Generated CSS, displayName, and componentId match main except the intended change. No runtime errors. +- Module formats: the plugin loads from CJS configs (`babel.config.js`, `.babelrc`) and ESM configs (`babel.config.mjs`, a `"type": "module"` project). Input covers ESM (default, named, aliased, and namespace imports) and CJS (`require`, `require(...).default`, destructured require). Babel's modules transform is checked both ways (preset-env `modules: false` and `'commonjs'`). Transformed output runs as native ESM and as CJS. +- Known interop facts: v5 and v6 expose styled as `module.exports.default` and have no `exports` map, so a default import under native Node ESM yields the module object (a packaging limit, not a plugin bug). v7 `require()` returns the callable styled with named exports attached. Non-tag exports (`isStyledComponent`, `createTheme`, `ThemeProvider`, `ServerStyleSheet`, and the rest of that set) must never receive `.withConfig`. +- Output changes (hash shifts, newly recognized forms, minify differences) need a changeset line even when intended. +- Perf: run `pnpm bench` on main and the branch for changes that touch visitors or detectors. A slower branch is a regression. + Performance - Hot-path code earns microbenchmarks before claiming it is optimized: A/B against the prior code at several input sizes (`pnpm bench`). Its run-to-run noise is large on big inputs, so repeat a run before trusting a single-digit difference. Babel plugins run per file per build, so per-node allocation and repeated AST walks matter. diff --git a/scripts/compat.js b/scripts/compat.js new file mode 100644 index 00000000..c7ab1e82 --- /dev/null +++ b/scripts/compat.js @@ -0,0 +1,458 @@ +// Compiles a handful of styled-components programs with the working tree and a +// base ref, then runs renderToString + ServerStyleSheet on styled-components +// v5, v6, and the latest v7 prerelease. Dev-only: those packages install into +// a temp cache, not this repo. Run with `node scripts/compat.js`. +const fs = require('fs') +const os = require('os') +const path = require('path') +const { spawnSync } = require('child_process') +const { parseArgs } = require('util') +const babel = require('@babel/core') +const presetEnvModule = require('@babel/preset-env') +const { buildRef, buildWorkingTree } = require('./bench/build-ref') +const { ROOT, withWorkDir } = require('./bench/shared') + +const presetEnv = presetEnvModule.default || presetEnvModule +const VIRTUAL_FILE = path.join(ROOT, 'compat-src', 'code.js') +const CACHE = path.join(os.tmpdir(), 'bpsc-styled-compat') +// v5 accepts React 18, and React 17 has no ESM entry Node can load for react-dom/server. +const REACT = { 5: '18.3.1', 6: '18.3.1' } + +const CASES = [ + { + name: 'cjs require, default tag and helpers', + sourceType: 'script', + majors: [5, 6, 7], + plain: ['isStyledComponent.withConfig', 'createTheme.withConfig', 'extractCSS.withConfig'], + code: ` +const sc = require('styled-components') +const { isStyledComponent } = sc +const Comp = sc.default.div\`color: red;\` +exports.Comp = Comp +exports.check = () => { + if (sc.isStyledComponent(Comp) !== true) throw new Error('member helper rejected the tag') + if (isStyledComponent(Comp) !== true) throw new Error('destructured helper rejected the tag') + if (typeof sc.createTheme === 'function') sc.createTheme({ color: 'red' }) + if (typeof sc.extractCSS === 'function') sc.extractCSS() +} +`, + }, + { + name: 'cjs require, direct tag', + sourceType: 'script', + majors: [7], + plain: ['isStyledComponent.withConfig'], + code: ` +const sc = require('styled-components') +const Comp = sc.div\`color: red;\` +exports.Comp = Comp +exports.check = () => { + if (sc.isStyledComponent(Comp) !== true) throw new Error('member helper rejected the tag') +} +`, + }, + { + name: 'cjs require bound as styled', + sourceType: 'script', + majors: [5, 6, 7], + plain: ['isStyledComponent.withConfig'], + code: ` +const styled = require('styled-components') +const Comp = styled.default.div\`color: red;\` +exports.Comp = Comp +exports.check = () => { + if (styled.isStyledComponent(Comp) !== true) throw new Error('member helper rejected the tag') +} +`, + }, + { + name: 'esm default and named helpers via commonjs transform', + sourceType: 'module', + modules: 'commonjs', + majors: [5, 6, 7], + plain: ['isStyledComponent.withConfig'], + code: ` +import styled, { css, isStyledComponent } from 'styled-components' +export const helper = css\`color: blue;\` +export const Comp = styled.div\`color: red;\` +export function check() { + if (isStyledComponent(Comp) !== true) throw new Error('named helper rejected the tag') +} +`, + }, + { + name: 'esm aliased styled via commonjs transform', + sourceType: 'module', + modules: 'commonjs', + majors: [6, 7], + plain: ['isStyledComponent.withConfig'], + code: ` +import { styled as sc, isStyledComponent } from 'styled-components' +export const Comp = sc.div\`color: red;\` +export function check() { + if (isStyledComponent(Comp) !== true) throw new Error('alias rejected the tag') +} +`, + }, + { + name: 'esm namespace via commonjs transform', + sourceType: 'module', + modules: 'commonjs', + majors: [5, 6, 7], + plain: ['isStyledComponent.withConfig'], + code: ` +import * as ns from 'styled-components' +export const Comp = ns.default.div\`color: red;\` +export function check() { + if (ns.isStyledComponent(Comp) !== true) throw new Error('namespace helper rejected the tag') +} +`, + }, + { + name: 'native esm, default import through .default', + sourceType: 'module', + modules: false, + majors: [5, 6, 7], + plain: ['isStyledComponent.withConfig'], + code: ` +import styled from 'styled-components' +export const Comp = styled.default.div\`color: red;\` +export function check() { + if (styled.isStyledComponent(Comp) !== true) throw new Error('esm helper rejected the tag') +} +`, + }, + { + name: 'native esm, direct tag', + sourceType: 'module', + modules: false, + majors: [5, 6, 7], + packagingLimitBelow: 7, + plain: ['isStyledComponent.withConfig'], + code: ` +import styled from 'styled-components' +export const Comp = styled.div\`color: red;\` +export function check() { + if (styled.isStyledComponent(Comp) !== true) throw new Error('esm helper rejected the tag') +} +`, + }, +] + +const parseOptions = argv => { + const { values } = parseArgs({ + args: argv, + strict: true, + options: { base: { type: 'string', default: 'origin/main' } }, + }) + return values +} + +const npmJson = args => { + const result = spawnSync('npm', args, { encoding: 'utf8' }) + if (result.status !== 0) { + throw new Error( + (result.stderr || result.stdout || `npm ${args.join(' ')} failed`).trim() + ) + } + return JSON.parse(result.stdout) +} + +const latestOfMajor = major => { + const data = npmJson(['view', `styled-components@${major}`, 'version', '--json']) + const versions = Array.isArray(data) ? data : [data] + return versions[versions.length - 1] +} + +const latestV7Prerelease = () => { + const tags = npmJson(['view', 'styled-components', 'dist-tags', '--json']) + const matches = Object.entries(tags).filter( + ([, version]) => version.startsWith('7.') && version.includes('-') + ) + if (!matches.length) throw new Error('no v7 prerelease dist-tag on styled-components') + matches.sort((left, right) => (left[1] < right[1] ? -1 : 1)) + return matches[matches.length - 1][1] +} + +const latestReact19 = () => { + const data = npmJson(['view', 'react@19', 'version', '--json']) + const versions = Array.isArray(data) ? data : [data] + return versions[versions.length - 1] +} + +const loadPlugin = entry => { + const loaded = require(entry) + return loaded.default || loaded +} + +const compile = (plugin, source) => { + const options = { + babelrc: false, + configFile: false, + filename: VIRTUAL_FILE, + sourceType: source.sourceType, + ast: false, + plugins: [[plugin, {}]], + } + if (source.modules !== undefined) { + options.presets = [[presetEnv, { modules: source.modules, targets: { node: '22' } }]] + } + return babel.transformSync(source.code, options).code +} + +const ensureRuntime = (spec, react) => { + const dir = path.join(CACHE, spec.replace(/[^a-z0-9.-]+/gi, '_')) + const stampPath = path.join(dir, '.compat-stamp') + const stamp = `${spec}\n${react}` + if (fs.existsSync(stampPath) && fs.readFileSync(stampPath, 'utf8') === stamp) return dir + fs.rmSync(dir, { recursive: true, force: true }) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ private: true })) + const result = spawnSync( + 'npm', + [ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + `styled-components@${spec}`, + `react@${react}`, + `react-dom@${react}`, + `react-is@${react}`, + ], + { cwd: dir, encoding: 'utf8', timeout: 180000 } + ) + if (result.status !== 0) { + throw new Error( + `install styled-components@${spec} failed\n${result.stderr || result.stdout}` + ) + } + fs.writeFileSync(stampPath, stamp) + return dir +} + +const CJS_RUNNER = ` +const React = require('react') +const { renderToString } = require('react-dom/server') +const sc = require('styled-components') +const compiled = require('./compiled.cjs') +const ServerStyleSheet = sc.ServerStyleSheet +const sheet = new ServerStyleSheet() +let html = null +let css = null +let renderError = null +try { + html = renderToString(sheet.collectStyles(React.createElement(compiled.Comp))) + css = sheet.getStyleTags() +} catch (error) { + renderError = error && error.message +} finally { + if (typeof sheet.seal === 'function') sheet.seal() +} +let checkError = null +try { + compiled.check() +} catch (error) { + checkError = error && error.message +} +const displayName = compiled.Comp && compiled.Comp.displayName +const ids = css ? css.match(/sc-[a-z0-9]+-\\d+/g) : null +process.stdout.write(JSON.stringify({ renderError, checkError, displayName, ids, css, html: html ? html.length : 0 })) +` + +const ESM_RUNNER = ` +import React from 'react' +import { renderToString } from 'react-dom/server' +import sc from 'styled-components' +import { Comp, check } from './compiled.mjs' +const ServerStyleSheet = sc.ServerStyleSheet +const sheet = new ServerStyleSheet() +let html = null +let css = null +let renderError = null +try { + html = renderToString(sheet.collectStyles(React.createElement(Comp))) + css = sheet.getStyleTags() +} catch (error) { + renderError = error && error.message +} finally { + if (typeof sheet.seal === 'function') sheet.seal() +} +let checkError = null +try { + check() +} catch (error) { + checkError = error && error.message +} +const displayName = Comp && Comp.displayName +const ids = css ? css.match(/sc-[a-z0-9]+-\\d+/g) : null +process.stdout.write(JSON.stringify({ renderError, checkError, displayName, ids, css, html: html ? html.length : 0 })) +` + +const execute = (runtimeDir, code, nativeEsm) => { + const dir = nativeEsm ? path.join(runtimeDir, 'esm') : runtimeDir + fs.mkdirSync(dir, { recursive: true }) + if (nativeEsm) { + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ private: true, type: 'module' }) + ) + const link = path.join(dir, 'node_modules') + if (!fs.existsSync(link)) { + fs.symlinkSync(path.join(runtimeDir, 'node_modules'), link, 'dir') + } + fs.writeFileSync(path.join(dir, 'compiled.mjs'), code) + fs.writeFileSync(path.join(dir, 'runner.mjs'), ESM_RUNNER) + } else { + fs.writeFileSync(path.join(dir, 'compiled.cjs'), code) + fs.writeFileSync(path.join(dir, 'runner.cjs'), CJS_RUNNER) + } + const runner = nativeEsm ? 'runner.mjs' : 'runner.cjs' + const result = spawnSync(process.execPath, [runner], { + cwd: dir, + encoding: 'utf8', + timeout: 30000, + }) + if (result.stdout && result.stdout.trim().startsWith('{')) { + return JSON.parse(result.stdout) + } + const detail = `${result.stderr || ''}${result.stdout || ''}`.trim() + const brief = detail.split('\n').filter(Boolean).slice(0, 2).join(' ') + return { + renderError: brief || `runner exited ${result.status}`, + checkError: null, + displayName: null, + ids: null, + css: null, + } +} + +const idsEqual = (left, right) => JSON.stringify(left || []) === JSON.stringify(right || []) + +const assertHeadShape = (name, code) => { + const source = CASES.find(item => item.name === name) + const problems = [] + if (!code.includes('withConfig')) problems.push('plugin did not add withConfig to a tag') + for (const marker of source.plain) { + if (code.includes(marker)) problems.push(`head output contains ${marker}`) + } + return problems +} + +const reportRun = (label, outcome) => { + const render = outcome.renderError ? `render FAIL ${outcome.renderError}` : 'render ok' + const check = outcome.checkError ? `check FAIL ${outcome.checkError}` : 'check ok' + const id = outcome.ids && outcome.ids.length ? outcome.ids.join(',') : 'none' + console.log( + ` ${label}: ${render}; ${check}; displayName=${outcome.displayName || 'none'}; componentId=${id}` + ) +} + +const compareRuns = (base, head) => { + if (base.renderError || head.renderError) return null + if (base.displayName !== head.displayName) { + return `displayName ${base.displayName} vs ${head.displayName}` + } + if (!idsEqual(base.ids, head.ids)) { + return `componentId ${(base.ids || []).join(',')} vs ${(head.ids || []).join(',')}` + } + if (base.css !== head.css) return 'generated CSS differs' + return null +} + +const loadFromConfig = (filename, contents) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bpsc-compat-config-')) + const configFile = path.join(dir, filename) + fs.writeFileSync(configFile, contents) + if (filename.endsWith('.mjs')) { + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ private: true, type: 'module' }) + ) + } + const output = babel.transformSync('const sc = require("styled-components"); sc.div`color: red;`', { + babelrc: false, + configFile, + filename: VIRTUAL_FILE, + sourceType: 'script', + ast: false, + }) + fs.rmSync(dir, { recursive: true, force: true }) + if (!output.code || !output.code.includes('withConfig')) { + throw new Error(`plugin did not run from ${filename}`) + } +} + +const checkConfigs = pluginEntry => { + const pluginPath = JSON.stringify(pluginEntry) + loadFromConfig('babel.config.js', `module.exports = { plugins: [${pluginPath}] }\n`) + loadFromConfig('.babelrc', JSON.stringify({ plugins: [pluginEntry] })) + loadFromConfig('babel.config.mjs', `export default { plugins: [${pluginPath}] }\n`) + console.log('configs: babel.config.js, .babelrc, and babel.config.mjs load the plugin') +} + +const main = async () => { + const options = parseOptions(process.argv.slice(2)) + const versions = [ + { major: 5, spec: latestOfMajor(5), react: REACT[5] }, + { major: 6, spec: latestOfMajor(6), react: REACT[6] }, + { major: 7, spec: latestV7Prerelease(), react: latestReact19() }, + ] + console.log( + versions.map(item => `v${item.major} ${item.spec} (react ${item.react})`).join(', ') + ) + const runtimes = new Map( + versions.map(item => [item.major, ensureRuntime(item.spec, item.react)]) + ) + + await withWorkDir(async workDir => { + const baseEntry = buildRef(options.base, path.join(workDir, 'base')) + const headEntry = buildWorkingTree(path.join(workDir, 'head')) + const basePlugin = loadPlugin(baseEntry) + const headPlugin = loadPlugin(headEntry) + checkConfigs(headEntry) + + for (const source of CASES) { + console.log(source.name) + const baseCode = compile(basePlugin, source) + const headCode = compile(headPlugin, source) + for (const problem of assertHeadShape(source.name, headCode)) { + console.error(` head: ${problem}`) + process.exitCode = 1 + } + for (const version of versions.filter(item => source.majors.includes(item.major))) { + console.log(` v${version.major} ${version.spec}`) + const nativeEsm = source.modules === false + const baseRun = execute(runtimes.get(version.major), baseCode, nativeEsm) + const headRun = execute(runtimes.get(version.major), headCode, nativeEsm) + reportRun('base', baseRun) + reportRun('head', headRun) + const knownLimit = + source.packagingLimitBelow && version.major < source.packagingLimitBelow + if (knownLimit && (headRun.renderError || headRun.checkError)) { + console.log(' known packaging limit on this version, not a plugin failure') + continue + } + if (headRun.renderError || headRun.checkError) process.exitCode = 1 + const mismatch = compareRuns(baseRun, headRun) + if (mismatch) { + console.error(` mismatch: ${mismatch}`) + process.exitCode = 1 + } else if (!baseRun.renderError && !headRun.renderError) { + console.log(' css, displayName, and componentId match') + } + } + } + }) + + if (process.exitCode) { + console.error('compat: failed') + } else { + console.log('compat: ok') + } +} + +main().catch(error => { + console.error(error && error.stack ? error.stack : error) + process.exitCode = 1 +}) From ffaa11e57e13959f8347470f00ad2b1c4fb595f2 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Thu, 22 Oct 2026 16:03:56 +0000 Subject: [PATCH 3/7] fix(detectors): leave non-tag exports alone on every member arm sc.default.isStyledComponent, namespace ns.default.isStyledComponent, and computed sc['isStyledComponent'] / sc.default['createTheme'] were still rewritten to .withConfig(). One property-name helper covers identifier and string keys. Real tags in those forms stay decorated. Co-authored-by: Evan Jacobs --- .changeset/require-non-tag-exports.md | 2 +- scripts/compat.js | 52 ++++++++++++++++++- src/utils/detectors.js | 17 ++++-- .../.babelrc | 14 +++++ .../namespace-default-non-tag-exports/code.js | 7 +++ .../output.js | 12 +++++ test/fixtures/require-non-tag-exports/code.js | 5 ++ .../require-non-tag-exports/output.js | 11 ++++ 8 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 test/fixtures/namespace-default-non-tag-exports/.babelrc create mode 100644 test/fixtures/namespace-default-non-tag-exports/code.js create mode 100644 test/fixtures/namespace-default-non-tag-exports/output.js diff --git a/.changeset/require-non-tag-exports.md b/.changeset/require-non-tag-exports.md index d12e6455..cb922700 100644 --- a/.changeset/require-non-tag-exports.md +++ b/.changeset/require-non-tag-exports.md @@ -2,4 +2,4 @@ 'babel-plugin-styled-components': patch --- -CommonJS files that call helpers like `isStyledComponent` on the required module crashed at runtime after the last release. `const sc = require('styled-components')` followed by `sc.isStyledComponent(A)` was rewritten into `sc.isStyledComponent.withConfig(...)(A)`, which throws `TypeError: sc.isStyledComponent.withConfig is not a function` on styled-components v5, v6, and v7. Those helper calls are left alone now, and real tags such as `sc.div` and `sc.default.div` still receive a display name and component id. +CommonJS files that call helpers like `isStyledComponent` on the required module crashed at runtime after the last release. `const sc = require('styled-components')` followed by `sc.isStyledComponent(A)` was rewritten into `sc.isStyledComponent.withConfig(...)(A)`, which throws `TypeError: sc.isStyledComponent.withConfig is not a function` on styled-components v5, v6, and v7. The same rewrite hit `sc.default.isStyledComponent(A)`, a namespace import (`ns.default.isStyledComponent(A)`), and computed access (`sc['isStyledComponent'](A)`, `sc.default['createTheme']({})`). Those calls are left alone now. Real tags written the same way, including `sc.div`, `sc.default.div`, `ns.default.div`, `sc['div']`, and `sc.default['div']`, still receive a display name and component id. diff --git a/scripts/compat.js b/scripts/compat.js index c7ab1e82..735292e9 100644 --- a/scripts/compat.js +++ b/scripts/compat.js @@ -99,12 +99,62 @@ export function check() { sourceType: 'module', modules: 'commonjs', majors: [5, 6, 7], - plain: ['isStyledComponent.withConfig'], + plain: ['isStyledComponent.withConfig', "['createTheme'].withConfig"], code: ` import * as ns from 'styled-components' export const Comp = ns.default.div\`color: red;\` export function check() { if (ns.isStyledComponent(Comp) !== true) throw new Error('namespace helper rejected the tag') + if (typeof ns.default.isStyledComponent === 'function') { + if (ns.default.isStyledComponent(Comp) !== true) { + throw new Error('namespace default helper rejected the tag') + } + } + if (typeof ns.default.createTheme === 'function') ns.default['createTheme']({ color: 'red' }) +} +`, + }, + { + name: 'cjs require, default member and computed helpers', + sourceType: 'script', + majors: [5, 6, 7], + plain: [ + 'isStyledComponent.withConfig', + "['isStyledComponent'].withConfig", + 'createTheme.withConfig', + "['createTheme'].withConfig", + ], + code: ` +const sc = require('styled-components') +const Comp = sc.default.div\`color: red;\` +const ViaComputedTag = sc.default['div']\`color: blue;\` +exports.Comp = Comp +exports.ViaComputedTag = ViaComputedTag +exports.check = () => { + if (sc['isStyledComponent'](Comp) !== true) throw new Error('computed helper rejected the tag') + if (sc['isStyledComponent'](ViaComputedTag) !== true) { + throw new Error('computed helper rejected the computed tag') + } + if (typeof sc.default.isStyledComponent === 'function') { + if (sc.default.isStyledComponent(Comp) !== true) { + throw new Error('default member helper rejected the tag') + } + } + if (typeof sc.default.createTheme === 'function') sc.default['createTheme']({ color: 'red' }) +} +`, + }, + { + name: 'cjs require, computed tag', + sourceType: 'script', + majors: [7], + plain: ['isStyledComponent.withConfig'], + code: ` +const sc = require('styled-components') +const Comp = sc['div']\`color: red;\` +exports.Comp = Comp +exports.check = () => { + if (sc['isStyledComponent'](Comp) !== true) throw new Error('computed helper rejected the tag') } `, }, diff --git a/src/utils/detectors.js b/src/utils/detectors.js index 5c8cb7fa..c6f6c989 100644 --- a/src/utils/detectors.js +++ b/src/utils/detectors.js @@ -195,6 +195,13 @@ const NON_TAG_EXPORTS = new Set([ 'version', ]) +// Static member key: `sc.div` and `sc['div']`. A computed non-literal has none. +const memberPropertyName = (t, property) => { + if (t.isIdentifier(property)) return property.name + if (t.isStringLiteral(property)) return property.value + return null +} + // `ns.default`: the callable default export reached through a namespace const isNamespaceDefault = (t, node, defaultLocal, state) => t.isMemberExpression(node) && @@ -226,9 +233,7 @@ export const isStyled = t => (tag, state) => { return ( (t.isMemberExpression(tag) && tag.property.name !== 'default' && - !( - t.isIdentifier(tag.property) && NON_TAG_EXPORTS.has(tag.property.name) - ) && + !NON_TAG_EXPORTS.has(memberPropertyName(t, tag.property)) && matchesDefault(tag.object.name) && !isHelper(t)(tag.property, state)) || (t.isCallExpression(tag) && matchesDefault(tag.callee.name)) || @@ -240,7 +245,8 @@ export const isStyled = t => (tag, state) => { t.isMemberExpression(tag) && t.isMemberExpression(tag.object) && tag.object.property.name === 'default' && - tag.object.object.name === state.styledRequired) || + tag.object.object.name === state.styledRequired && + !NON_TAG_EXPORTS.has(memberPropertyName(t, tag.property))) || (state.styledRequired && t.isCallExpression(tag) && t.isMemberExpression(tag.callee) && @@ -254,7 +260,8 @@ export const isStyled = t => (tag, state) => { getSequenceExpressionValue(tag.callee).object.name === state.styledRequired) || (t.isMemberExpression(tag) && - isNamespaceDefault(t, tag.object, defaultLocal, state)) || + isNamespaceDefault(t, tag.object, defaultLocal, state) && + !NON_TAG_EXPORTS.has(memberPropertyName(t, tag.property))) || (t.isCallExpression(tag) && isNamespaceDefault(t, tag.callee, defaultLocal, state)) || (t.isCallExpression(tag) && diff --git a/test/fixtures/namespace-default-non-tag-exports/.babelrc b/test/fixtures/namespace-default-non-tag-exports/.babelrc new file mode 100644 index 00000000..4f8903d2 --- /dev/null +++ b/test/fixtures/namespace-default-non-tag-exports/.babelrc @@ -0,0 +1,14 @@ +{ + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/namespace-default-non-tag-exports/code.js b/test/fixtures/namespace-default-non-tag-exports/code.js new file mode 100644 index 00000000..ec8bd05f --- /dev/null +++ b/test/fixtures/namespace-default-non-tag-exports/code.js @@ -0,0 +1,7 @@ +import * as ns from 'styled-components' + +const Tagged = ns.default.div`color: red;` +const ok = ns.default.isStyledComponent(Tagged) +const theme = ns.default['createTheme']({}) +const viaComputed = ns['isStyledComponent'](Tagged) +const Also = ns.default['div']`color: blue;` diff --git a/test/fixtures/namespace-default-non-tag-exports/output.js b/test/fixtures/namespace-default-non-tag-exports/output.js new file mode 100644 index 00000000..6ebbffad --- /dev/null +++ b/test/fixtures/namespace-default-non-tag-exports/output.js @@ -0,0 +1,12 @@ +import * as ns from 'styled-components'; +const Tagged = ns.default.div.withConfig({ + displayName: "code__Tagged", + componentId: "sc-18vnb1u-0" +})(["color: red;"]); +const ok = ns.default.isStyledComponent(Tagged); +const theme = ns.default['createTheme']({}); +const viaComputed = ns['isStyledComponent'](Tagged); +const Also = ns.default['div'].withConfig({ + displayName: "code__Also", + componentId: "sc-18vnb1u-1" +})(["color: blue;"]); diff --git a/test/fixtures/require-non-tag-exports/code.js b/test/fixtures/require-non-tag-exports/code.js index abf7424d..5272ccfe 100644 --- a/test/fixtures/require-non-tag-exports/code.js +++ b/test/fixtures/require-non-tag-exports/code.js @@ -7,3 +7,8 @@ const theme = sc.createTheme({}) const extracted = sc.extractCSS() const sheet = sc.toStyleSheet() const factory = sc.styled(A) +const viaDefault = sc.default.isStyledComponent(A) +const viaComputed = sc['isStyledComponent'](A) +const themeComputed = sc.default['createTheme']({}) +const C = sc['div']`color: green;` +const D = sc.default['div']`color: orange;` diff --git a/test/fixtures/require-non-tag-exports/output.js b/test/fixtures/require-non-tag-exports/output.js index 50de1289..3528aead 100644 --- a/test/fixtures/require-non-tag-exports/output.js +++ b/test/fixtures/require-non-tag-exports/output.js @@ -12,3 +12,14 @@ const theme = sc.createTheme({}); const extracted = sc.extractCSS(); const sheet = sc.toStyleSheet(); const factory = sc.styled(A); +const viaDefault = sc.default.isStyledComponent(A); +const viaComputed = sc['isStyledComponent'](A); +const themeComputed = sc.default['createTheme']({}); +const C = sc['div'].withConfig({ + displayName: "code__C", + componentId: "sc-c84c7x-2" +})(["color: green;"]); +const D = sc.default['div'].withConfig({ + displayName: "code__D", + componentId: "sc-c84c7x-3" +})(["color: orange;"]); From 79ccd04f48fb531b27c032f5140a8b25a3b353fb Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Thu, 22 Oct 2026 16:14:41 +0000 Subject: [PATCH 4/7] fix(detectors): don't decorate helper exports on member access css, keyframes, createGlobalStyle, useTheme, and withTheme were still rewritten to .withConfig() through sc.default.css and sc['css']. Those names are non-tag exports, so every member arm leaves them alone. Co-authored-by: Evan Jacobs --- .changeset/require-non-tag-exports.md | 2 +- scripts/compat.js | 14 +++++++++++++- src/utils/detectors.js | 11 +++++++++-- .../namespace-default-non-tag-exports/code.js | 2 ++ .../namespace-default-non-tag-exports/output.js | 2 ++ test/fixtures/require-non-tag-exports/code.js | 3 +++ test/fixtures/require-non-tag-exports/output.js | 3 +++ 7 files changed, 33 insertions(+), 4 deletions(-) diff --git a/.changeset/require-non-tag-exports.md b/.changeset/require-non-tag-exports.md index cb922700..9b8f4c35 100644 --- a/.changeset/require-non-tag-exports.md +++ b/.changeset/require-non-tag-exports.md @@ -2,4 +2,4 @@ 'babel-plugin-styled-components': patch --- -CommonJS files that call helpers like `isStyledComponent` on the required module crashed at runtime after the last release. `const sc = require('styled-components')` followed by `sc.isStyledComponent(A)` was rewritten into `sc.isStyledComponent.withConfig(...)(A)`, which throws `TypeError: sc.isStyledComponent.withConfig is not a function` on styled-components v5, v6, and v7. The same rewrite hit `sc.default.isStyledComponent(A)`, a namespace import (`ns.default.isStyledComponent(A)`), and computed access (`sc['isStyledComponent'](A)`, `sc.default['createTheme']({})`). Those calls are left alone now. Real tags written the same way, including `sc.div`, `sc.default.div`, `ns.default.div`, `sc['div']`, and `sc.default['div']`, still receive a display name and component id. +CommonJS files that call helpers like `isStyledComponent` on the required module crashed at runtime after the last release. `const sc = require('styled-components')` followed by `sc.isStyledComponent(A)` was rewritten into `sc.isStyledComponent.withConfig(...)(A)`, which throws `TypeError: sc.isStyledComponent.withConfig is not a function` on styled-components v5, v6, and v7. The same rewrite hit `sc.default.isStyledComponent(A)`, a namespace import (`ns.default.isStyledComponent(A)`), and computed access (`sc['isStyledComponent'](A)`, `sc.default['createTheme']({})`). Helper exports did it too, including `sc.default.css` and `sc['css']`. Those calls are left alone now. Real tags written the same way, including `sc.div`, `sc.default.div`, `ns.default.div`, `sc['div']`, and `sc.default['div']`, still receive a display name and component id. diff --git a/scripts/compat.js b/scripts/compat.js index 735292e9..dae991a3 100644 --- a/scripts/compat.js +++ b/scripts/compat.js @@ -99,7 +99,12 @@ export function check() { sourceType: 'module', modules: 'commonjs', majors: [5, 6, 7], - plain: ['isStyledComponent.withConfig', "['createTheme'].withConfig"], + plain: [ + 'isStyledComponent.withConfig', + "['createTheme'].withConfig", + 'css.withConfig', + "['css'].withConfig", + ], code: ` import * as ns from 'styled-components' export const Comp = ns.default.div\`color: red;\` @@ -111,6 +116,8 @@ export function check() { } } if (typeof ns.default.createTheme === 'function') ns.default['createTheme']({ color: 'red' }) + if (typeof ns['css'] === 'function') ns['css']\`color: red;\` + if (typeof ns.default.css === 'function') ns.default.css\`color: blue;\` } `, }, @@ -123,6 +130,8 @@ export function check() { "['isStyledComponent'].withConfig", 'createTheme.withConfig', "['createTheme'].withConfig", + 'css.withConfig', + "['css'].withConfig", ], code: ` const sc = require('styled-components') @@ -141,6 +150,9 @@ exports.check = () => { } } if (typeof sc.default.createTheme === 'function') sc.default['createTheme']({ color: 'red' }) + if (typeof sc['css'] === 'function') sc['css']\`color: red;\` + if (typeof sc.default.css === 'function') sc.default.css\`color: blue;\` + if (typeof sc['keyframes'] === 'function') sc['keyframes']\`from { opacity: 0; } to { opacity: 1; }\` } `, }, diff --git a/src/utils/detectors.js b/src/utils/detectors.js index c6f6c989..0609458c 100644 --- a/src/utils/detectors.js +++ b/src/utils/detectors.js @@ -162,8 +162,10 @@ const globalStyledAlias = (state, defaultLocal) => // Named exports that are never styled tags. A required binding is the module // object (v5, v6) or the callable `styled` with these attached (v7), so -// `sc.isStyledComponent(x)` must not be decorated. Helpers stay with isHelper. -// Intrinsic tags (div, View, and the rest) are not in this set. +// `sc.isStyledComponent(x)` must not be decorated. Helper names are included +// because isHelper only matches identifier keys, and `sc.default.css` / +// `sc['css']` are member expressions. Intrinsic tags (div, View, and the rest) +// are not in this set. const NON_TAG_EXPORTS = new Set([ 'NativeStyleContext', 'ParentContext', @@ -177,9 +179,12 @@ const NON_TAG_EXPORTS = new Set([ 'ThemeProvider', '__PRIVATE__', '__testOnly_verticalAlignCompanions', + 'createGlobalStyle', 'createTheme', + 'css', 'extractCSS', 'isStyledComponent', + 'keyframes', 'matchMedia', 'setAnimationDebug', 'styled', @@ -192,7 +197,9 @@ const NON_TAG_EXPORTS = new Set([ 'useMediaQuery', 'useNativeStyleContext', 'useParentContext', + 'useTheme', 'version', + 'withTheme', ]) // Static member key: `sc.div` and `sc['div']`. A computed non-literal has none. diff --git a/test/fixtures/namespace-default-non-tag-exports/code.js b/test/fixtures/namespace-default-non-tag-exports/code.js index ec8bd05f..d3e2d96f 100644 --- a/test/fixtures/namespace-default-non-tag-exports/code.js +++ b/test/fixtures/namespace-default-non-tag-exports/code.js @@ -5,3 +5,5 @@ const ok = ns.default.isStyledComponent(Tagged) const theme = ns.default['createTheme']({}) const viaComputed = ns['isStyledComponent'](Tagged) const Also = ns.default['div']`color: blue;` +const helperCss = ns.default.css`color: green;` +const helperComputed = ns['css']`color: purple;` diff --git a/test/fixtures/namespace-default-non-tag-exports/output.js b/test/fixtures/namespace-default-non-tag-exports/output.js index 6ebbffad..78cdfc46 100644 --- a/test/fixtures/namespace-default-non-tag-exports/output.js +++ b/test/fixtures/namespace-default-non-tag-exports/output.js @@ -10,3 +10,5 @@ const Also = ns.default['div'].withConfig({ displayName: "code__Also", componentId: "sc-18vnb1u-1" })(["color: blue;"]); +const helperCss = ns.default.css`color: green;`; +const helperComputed = ns['css']`color: purple;`; diff --git a/test/fixtures/require-non-tag-exports/code.js b/test/fixtures/require-non-tag-exports/code.js index 5272ccfe..90175add 100644 --- a/test/fixtures/require-non-tag-exports/code.js +++ b/test/fixtures/require-non-tag-exports/code.js @@ -12,3 +12,6 @@ const viaComputed = sc['isStyledComponent'](A) const themeComputed = sc.default['createTheme']({}) const C = sc['div']`color: green;` const D = sc.default['div']`color: orange;` +const helperCss = sc['css']`color: purple;` +const helperDefaultCss = sc.default.css`color: pink;` +const helperKeyframes = sc['keyframes']`from { opacity: 0; } to { opacity: 1; }` diff --git a/test/fixtures/require-non-tag-exports/output.js b/test/fixtures/require-non-tag-exports/output.js index 3528aead..e22ee300 100644 --- a/test/fixtures/require-non-tag-exports/output.js +++ b/test/fixtures/require-non-tag-exports/output.js @@ -23,3 +23,6 @@ const D = sc.default['div'].withConfig({ displayName: "code__D", componentId: "sc-c84c7x-3" })(["color: orange;"]); +const helperCss = sc['css']`color: purple;`; +const helperDefaultCss = sc.default.css`color: pink;`; +const helperKeyframes = sc['keyframes']`from { opacity: 0; } to { opacity: 1; }`; From 96da99bc05b062b9b3951a8f77f87a1ff8322d1e Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Thu, 22 Oct 2026 17:59:25 +0000 Subject: [PATCH 5/7] fix(detectors): allowlist members on require, alias, and namespace bindings Require, alias, and namespace bindings only get .withConfig for intrinsic tags and styled() calls, including computed ['default']. A local named styled still decorates unknown properties. Shadowed or reassigned locals are resolved through the binding, and a computed variable key stays a dynamic lookup. Co-authored-by: Evan Jacobs --- .changeset/require-non-tag-exports.md | 4 +- AGENTS.md | 3 +- scripts/compat.js | 187 +++++++- src/utils/detectors.js | 398 +++++++++++------- src/utils/domElements.js | 200 +++++++++ src/visitors/assignStyledRequired.js | 32 +- src/visitors/displayNameAndId.js | 12 +- src/visitors/minify.js | 3 +- src/visitors/pure.js | 4 +- src/visitors/templateLiterals/transpile.js | 5 +- .../aliased-import-allowlist/.babelrc | 14 + .../fixtures/aliased-import-allowlist/code.js | 8 + .../aliased-import-allowlist/output.js | 13 + .../.babelrc | 15 + .../code.js | 7 + .../output.js | 17 + .../.babelrc | 15 + .../css-prop-with-named-styled-import/code.js | 4 + .../output.js | 10 + .../default-import-unknown-member/.babelrc | 14 + .../default-import-unknown-member/code.js | 6 + .../default-import-unknown-member/output.js | 11 + test/fixtures/namespace-direct-call/.babelrc | 14 + test/fixtures/namespace-direct-call/code.js | 13 + test/fixtures/namespace-direct-call/output.js | 23 + .../require-computed-default/.babelrc | 15 + .../fixtures/require-computed-default/code.js | 9 + .../require-computed-default/output.js | 18 + .../require-computed-variable-key/.babelrc | 15 + .../require-computed-variable-key/code.js | 4 + .../require-computed-variable-key/output.js | 6 + test/fixtures/require-default-alias/.babelrc | 15 + test/fixtures/require-default-alias/code.js | 6 + test/fixtures/require-default-alias/output.js | 8 + .../fixtures/require-default-binding/.babelrc | 15 + test/fixtures/require-default-binding/code.js | 11 + .../require-default-binding/output.js | 15 + test/fixtures/require-object-methods/.babelrc | 15 + test/fixtures/require-object-methods/code.js | 5 + .../fixtures/require-object-methods/output.js | 7 + test/fixtures/require-reassign/.babelrc | 15 + test/fixtures/require-reassign/code.js | 7 + test/fixtures/require-reassign/output.js | 10 + test/fixtures/require-shadow-local/.babelrc | 15 + test/fixtures/require-shadow-local/code.js | 15 + test/fixtures/require-shadow-local/output.js | 19 + test/fixtures/require-shadow-param/.babelrc | 15 + test/fixtures/require-shadow-param/code.js | 8 + test/fixtures/require-shadow-param/output.js | 11 + test/fixtures/require-unknown-export/.babelrc | 15 + test/fixtures/require-unknown-export/code.js | 9 + .../fixtures/require-unknown-export/output.js | 10 + .../require-unrelated-styled-binding/.babelrc | 15 + .../require-unrelated-styled-binding/code.js | 5 + .../output.js | 9 + .../string-named-styled-import/.babelrc | 14 + .../string-named-styled-import/code.js | 5 + .../string-named-styled-import/output.js | 7 + .../styled-alias-ts-assertion/.babelrc | 17 + .../styled-alias-ts-assertion/code.js | 8 + .../styled-alias-ts-assertion/output.js | 12 + 61 files changed, 1254 insertions(+), 183 deletions(-) create mode 100644 src/utils/domElements.js create mode 100644 test/fixtures/aliased-import-allowlist/.babelrc create mode 100644 test/fixtures/aliased-import-allowlist/code.js create mode 100644 test/fixtures/aliased-import-allowlist/output.js create mode 100644 test/fixtures/css-prop-script-mode-require-default-after-css-prop/.babelrc create mode 100644 test/fixtures/css-prop-script-mode-require-default-after-css-prop/code.js create mode 100644 test/fixtures/css-prop-script-mode-require-default-after-css-prop/output.js create mode 100644 test/fixtures/css-prop-with-named-styled-import/.babelrc create mode 100644 test/fixtures/css-prop-with-named-styled-import/code.js create mode 100644 test/fixtures/css-prop-with-named-styled-import/output.js create mode 100644 test/fixtures/default-import-unknown-member/.babelrc create mode 100644 test/fixtures/default-import-unknown-member/code.js create mode 100644 test/fixtures/default-import-unknown-member/output.js create mode 100644 test/fixtures/namespace-direct-call/.babelrc create mode 100644 test/fixtures/namespace-direct-call/code.js create mode 100644 test/fixtures/namespace-direct-call/output.js create mode 100644 test/fixtures/require-computed-default/.babelrc create mode 100644 test/fixtures/require-computed-default/code.js create mode 100644 test/fixtures/require-computed-default/output.js create mode 100644 test/fixtures/require-computed-variable-key/.babelrc create mode 100644 test/fixtures/require-computed-variable-key/code.js create mode 100644 test/fixtures/require-computed-variable-key/output.js create mode 100644 test/fixtures/require-default-alias/.babelrc create mode 100644 test/fixtures/require-default-alias/code.js create mode 100644 test/fixtures/require-default-alias/output.js create mode 100644 test/fixtures/require-default-binding/.babelrc create mode 100644 test/fixtures/require-default-binding/code.js create mode 100644 test/fixtures/require-default-binding/output.js create mode 100644 test/fixtures/require-object-methods/.babelrc create mode 100644 test/fixtures/require-object-methods/code.js create mode 100644 test/fixtures/require-object-methods/output.js create mode 100644 test/fixtures/require-reassign/.babelrc create mode 100644 test/fixtures/require-reassign/code.js create mode 100644 test/fixtures/require-reassign/output.js create mode 100644 test/fixtures/require-shadow-local/.babelrc create mode 100644 test/fixtures/require-shadow-local/code.js create mode 100644 test/fixtures/require-shadow-local/output.js create mode 100644 test/fixtures/require-shadow-param/.babelrc create mode 100644 test/fixtures/require-shadow-param/code.js create mode 100644 test/fixtures/require-shadow-param/output.js create mode 100644 test/fixtures/require-unknown-export/.babelrc create mode 100644 test/fixtures/require-unknown-export/code.js create mode 100644 test/fixtures/require-unknown-export/output.js create mode 100644 test/fixtures/require-unrelated-styled-binding/.babelrc create mode 100644 test/fixtures/require-unrelated-styled-binding/code.js create mode 100644 test/fixtures/require-unrelated-styled-binding/output.js create mode 100644 test/fixtures/string-named-styled-import/.babelrc create mode 100644 test/fixtures/string-named-styled-import/code.js create mode 100644 test/fixtures/string-named-styled-import/output.js create mode 100644 test/fixtures/styled-alias-ts-assertion/.babelrc create mode 100644 test/fixtures/styled-alias-ts-assertion/code.js create mode 100644 test/fixtures/styled-alias-ts-assertion/output.js diff --git a/.changeset/require-non-tag-exports.md b/.changeset/require-non-tag-exports.md index 9b8f4c35..cb7dd16b 100644 --- a/.changeset/require-non-tag-exports.md +++ b/.changeset/require-non-tag-exports.md @@ -2,4 +2,6 @@ 'babel-plugin-styled-components': patch --- -CommonJS files that call helpers like `isStyledComponent` on the required module crashed at runtime after the last release. `const sc = require('styled-components')` followed by `sc.isStyledComponent(A)` was rewritten into `sc.isStyledComponent.withConfig(...)(A)`, which throws `TypeError: sc.isStyledComponent.withConfig is not a function` on styled-components v5, v6, and v7. The same rewrite hit `sc.default.isStyledComponent(A)`, a namespace import (`ns.default.isStyledComponent(A)`), and computed access (`sc['isStyledComponent'](A)`, `sc.default['createTheme']({})`). Helper exports did it too, including `sc.default.css` and `sc['css']`. Those calls are left alone now. Real tags written the same way, including `sc.div`, `sc.default.div`, `ns.default.div`, `sc['div']`, and `sc.default['div']`, still receive a display name and component id. +CommonJS and aliased styled-components bindings no longer treat every property as a component. `const sc = require('styled-components')` followed by `sc.isStyledComponent(A)`, `sc.hasOwnProperty('css')`, `sc.toString()`, or a property this plugin does not know (`sc.somethingNew`) was rewritten into a `.withConfig(...)` call and crashed on styled-components v5, v6, and v7. The same crash happened through `sc.default`, `sc['default']`, a namespace import, an aliased import (`import sc` or `import { styled as s }`), and a local `sc` that shadowed or replaced the require. Those are left alone now. + +Tags and real `styled()` calls still get a display name and component id, including `sc.div`, `sc['div']`, `sc.default.div`, `sc['default'].div`, `sc(Comp)`, `sc.default(Comp)`, and `sc['default'](Comp)`. A default or named import whose local name is `styled` still decorates unknown properties, so a future tag on the v7 proxy keeps working. A computed key that is a variable (`sc.default[name]`) is decorated the way it was before, instead of being mistaken for a helper. diff --git a/AGENTS.md b/AGENTS.md index 735972e1..73ca9df9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,8 @@ Review - Every behavior change is checked against styled-components v5 (latest 5.x), v6 (latest 6.x), and v7 (latest prerelease dist-tag). `node scripts/compat.js` compiles smoke programs with the branch and with main, then executes the output (`renderToString` plus `ServerStyleSheet`) against each version. Generated CSS, displayName, and componentId match main except the intended change. No runtime errors. - Module formats: the plugin loads from CJS configs (`babel.config.js`, `.babelrc`) and ESM configs (`babel.config.mjs`, a `"type": "module"` project). Input covers ESM (default, named, aliased, and namespace imports) and CJS (`require`, `require(...).default`, destructured require). Babel's modules transform is checked both ways (preset-env `modules: false` and `'commonjs'`). Transformed output runs as native ESM and as CJS. -- Known interop facts: v5 and v6 expose styled as `module.exports.default` and have no `exports` map, so a default import under native Node ESM yields the module object (a packaging limit, not a plugin bug). v7 `require()` returns the callable styled with named exports attached. Non-tag exports (`isStyledComponent`, `createTheme`, `ThemeProvider`, `ServerStyleSheet`, and the rest of that set) must never receive `.withConfig`. +- Known interop facts: v5 and v6 expose styled as `module.exports.default` and have no `exports` map, so a default import under native Node ESM yields the module object (a packaging limit, not a plugin bug). v7 `require()` returns the callable styled with named exports attached. On `require`, an aliased import, and a namespace import, only intrinsic tags and `styled()` calls receive `.withConfig`. A default or named import whose local name is `styled` still decorates unknown properties. Object methods, known non-tag exports, and a local that shadows or replaces the binding never receive `.withConfig`. A computed variable key is a dynamic lookup, not the identifier's spelling. +- A change to `isStyled` or the helper detectors is judged only after generating the full shape matrix: every import form (default, named, aliased, namespace, require, interop helper) x access form (dot, `.default`, computed literal, computed variable, call, `.attrs` / `.withConfig` chain, sequence) x export (intrinsic tags, known helpers, object methods, unknown names) x option preset. `node scripts/compat.js` runs a compact version of that matrix against v5, v6, and the latest v7 prerelease. A difference from the last release is a regression unless it is the intended fix. - Output changes (hash shifts, newly recognized forms, minify differences) need a changeset line even when intended. - Perf: run `pnpm bench` on main and the branch for changes that touch visitors or detectors. A slower branch is a regression. diff --git a/scripts/compat.js b/scripts/compat.js index dae991a3..1588f78d 100644 --- a/scripts/compat.js +++ b/scripts/compat.js @@ -201,6 +201,176 @@ export function check() { }, ] +// Compact import x access x export matrix. Compile-only: the full generator is +// the audit harness, and this is the slice `node scripts/compat.js` always runs. +const MATRIX_TAGS = new Set(['div', 'View']) +// Open mode (`import styled`) still decorates unknown names. It skips object +// methods and known non-tag exports, same as OPEN_SKIP in the detector. +const MATRIX_OPEN_SKIP = new Set([ + 'isStyledComponent', + 'css', + 'hasOwnProperty', + 'toString', +]) +const MATRIX_IMPORTS = [ + { + name: 'require sc', + mode: 'allow', + sourceType: 'script', + bind: 'sc', + head: `const sc = require('styled-components')`, + }, + { + name: 'import styled', + mode: 'open', + sourceType: 'module', + bind: 'styled', + head: `import styled from 'styled-components'`, + }, + { + name: 'import sc', + mode: 'allow', + sourceType: 'module', + bind: 'sc', + head: `import sc from 'styled-components'`, + }, + { + name: 'import { styled as s }', + mode: 'allow', + sourceType: 'module', + bind: 's', + head: `import { styled as s } from 'styled-components'`, + }, + { + name: 'import * as ns', + mode: 'namespace', + sourceType: 'module', + bind: 'ns', + head: `import * as ns from 'styled-components'`, + }, +] +const MATRIX_ACCESS = [ + { name: 'dot', expr: (b, x) => `${b}.${x}` }, + { name: 'default.dot', expr: (b, x) => `${b}.default.${x}` }, + { name: 'computed', expr: (b, x) => `${b}['${x}']` }, + { name: 'default.computed', expr: (b, x) => `${b}.default['${x}']` }, + { name: 'computed-default.dot', expr: (b, x) => `${b}['default'].${x}` }, +] +const MATRIX_EXPORTS = ['div', 'View', 'isStyledComponent', 'css', 'hasOwnProperty', 'somethingNew'] +const matrixMemberDecorated = (mode, name) => { + if (mode === 'open') return !MATRIX_OPEN_SKIP.has(name) + return MATRIX_TAGS.has(name) +} + +for (const imp of MATRIX_IMPORTS) { + for (const access of MATRIX_ACCESS) { + for (const name of MATRIX_EXPORTS) { + const decorated = matrixMemberDecorated(imp.mode, name) + CASES.push({ + name: `matrix ${imp.name} ${access.name} ${name}`, + sourceType: imp.sourceType, + compileOnly: true, + expectWithConfig: decorated, + code: `${imp.head}\nconst Comp = ${access.expr(imp.bind, name)}\`color: red;\`\n`, + }) + } + } + for (const call of [ + { name: 'call', expr: b => `${b}(Base)`, absent: [] }, + { name: 'default.call', expr: b => `${b}.default(Base)`, absent: [] }, + { + name: 'computed-default.call', + expr: b => `${b}['default'](Base)`, + absent: [`['default'].withConfig`], + present: [`['default'](Base).withConfig`], + }, + ]) { + CASES.push({ + name: `matrix ${imp.name} ${call.name}`, + sourceType: imp.sourceType, + compileOnly: true, + expectWithConfig: true, + absent: call.absent, + present: call.present, + code: `${imp.head}\nfunction Base(props) { return props.children || null }\nconst Comp = ${call.expr(imp.bind)}` + '`color: red;`\n', + }) + } +} + +CASES.push( + { + name: 'matrix shadow param is not the require', + sourceType: 'script', + compileOnly: true, + expectWithConfig: true, + absent: ['sc.div.withConfig'], + code: ` +const sc = require('styled-components') +function make(sc) { return sc.div\`color: red;\` } +const Comp = make({ div: s => () => null }) +const Real = sc.default.div\`color: blue;\` +`, + }, + { + name: 'matrix reassigned require is not decorated', + sourceType: 'script', + compileOnly: true, + expectWithConfig: true, + absent: ['sc.div.withConfig'], + code: ` +const real = require('styled-components') +let sc = require('styled-components') +sc = { div: s => () => null } +const Comp = sc.div\`color: red;\` +const Real = real.default.div\`color: blue;\` +`, + }, + { + name: 'matrix computed variable key stays decorated', + sourceType: 'script', + compileOnly: true, + expectWithConfig: true, + present: ['sc.default[css].withConfig'], + code: ` +const sc = require('styled-components') +const css = 'div' +const Comp = sc.default[css]\`color: red;\` +`, + }, + { + name: 'matrix object method and unknown export do not crash', + sourceType: 'script', + majors: [5, 6, 7], + plain: ['hasOwnProperty.withConfig', 'toString.withConfig', 'somethingNew.withConfig'], + code: ` +const sc = require('styled-components') +const Comp = sc.default.div\`color: red;\` +exports.Comp = Comp +exports.check = () => { + const has = sc.hasOwnProperty('css') + if (typeof has !== 'boolean') throw new Error('hasOwnProperty was rewritten') + if (typeof sc.toString() !== 'string') throw new Error('toString was rewritten') +} +`, + }, + { + name: 'matrix computed default call decorates the result', + sourceType: 'script', + majors: [5, 6, 7], + plain: ["['default'].withConfig"], + present: ["['default'](Base).withConfig"], + code: ` +const sc = require('styled-components') +function Base(props) { return props.children || null } +const Comp = sc['default'](Base)\`color: red;\` +exports.Comp = Comp +exports.check = () => { + if (!Comp.styledComponentId) throw new Error('computed default call was not decorated') +} +`, + } +) + const parseOptions = argv => { const { values } = parseArgs({ args: argv, @@ -394,10 +564,20 @@ const idsEqual = (left, right) => JSON.stringify(left || []) === JSON.stringify( const assertHeadShape = (name, code) => { const source = CASES.find(item => item.name === name) const problems = [] - if (!code.includes('withConfig')) problems.push('plugin did not add withConfig to a tag') - for (const marker of source.plain) { + if (source.expectWithConfig === false) { + if (code.includes('withConfig')) problems.push('unexpected withConfig') + } else if (!code.includes('withConfig')) { + problems.push('plugin did not add withConfig to a tag') + } + for (const marker of source.plain || []) { + if (code.includes(marker)) problems.push(`head output contains ${marker}`) + } + for (const marker of source.absent || []) { if (code.includes(marker)) problems.push(`head output contains ${marker}`) } + for (const marker of source.present || []) { + if (!code.includes(marker)) problems.push(`head output missing ${marker}`) + } return problems } @@ -476,12 +656,13 @@ const main = async () => { for (const source of CASES) { console.log(source.name) - const baseCode = compile(basePlugin, source) const headCode = compile(headPlugin, source) for (const problem of assertHeadShape(source.name, headCode)) { console.error(` head: ${problem}`) process.exitCode = 1 } + if (source.compileOnly) continue + const baseCode = compile(basePlugin, source) for (const version of versions.filter(item => source.majors.includes(item.major))) { console.log(` v${version.major} ${version.spec}`) const nativeEsm = source.modules === false diff --git a/src/utils/detectors.js b/src/utils/detectors.js index 0609458c..4163aa18 100644 --- a/src/utils/detectors.js +++ b/src/utils/detectors.js @@ -1,3 +1,4 @@ +import { isStyledTag } from './domElements' import getSequenceExpressionValue from './getSequenceExpressionValue' import { useTopLevelImportPathMatchers } from './options' @@ -18,6 +19,25 @@ const isTypeOnly = node => const IMPORT_NAMES_CACHE = 'styled-components-import-local-names' const INJECTED_DEFAULT_NAME = 'styled-components-injected-default-name' +const REQUIRED_NAMES = 'styled-components-required-names' + +// Names whose require() declarator assignStyledRequired has already walked. +// The css-prop pre-pass runs mid-traversal, and a require that sits below the +// first css prop must not be numbered there. +export const noteStyledRequired = (state, name) => { + if (!name) return + let names = state.file.get(REQUIRED_NAMES) + if (!names) { + names = new Set() + state.file.set(REQUIRED_NAMES, names) + } + names.add(name) +} + +const styledRequireVisited = (state, name) => { + const names = state.file.get(REQUIRED_NAMES) + return !!name && !!names && names.has(name) +} // The default binding the css-prop transform injected, which can be a // `require` (script files) that no ImportDeclaration scan would find @@ -92,81 +112,19 @@ export const importLocalName = (name, state) => { return localName } -const NAMESPACE_NAMES_CACHE = 'styled-components-import-namespace-names' -const NO_NAMESPACE_NAMES = [] - -// Local names bound by `import * as X from 'styled-components'`. Kept apart -// from `importLocalName('default')` so a default import injected later (the -// css-prop transform) does not displace the namespace binding. -const importNamespaceNames = state => { - if (state.customImportName) return NO_NAMESPACE_NAMES - - let names = state.file.get(NAMESPACE_NAMES_CACHE) - if (names) return names - - names = [] - for (const node of state.file.path.node.body) { - if (node.type !== 'ImportDeclaration') continue - if (isTypeOnly(node)) continue - if (!isValidTopLevelImport(node.source.value, state)) continue - - for (const specifier of node.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') { - names.push(specifier.local.name) - } - } - } - - state.file.set(NAMESPACE_NAMES_CACHE, names) - return names -} - -// Follow `const X = Y` (and TS `const X = Y as Type`) chains so a local -// re-binding of the styled import still resolves to it. Lazy: only walks -// the chain when the direct-name check misses, so the hot path is unchanged. -const resolvesToDefaultLocal = (name, defaultLocal, state, t) => { - if (!name || !defaultLocal) return false - if (name === defaultLocal) return true - const scope = state.file.path.scope - const visited = new Set([name]) - let current = name - while (true) { - const binding = scope.getBinding(current) - if (!binding || !binding.path.isVariableDeclarator()) return false - const init = binding.path.node.init - if (!init) return false - let nextName = null - if (t.isIdentifier(init)) { - nextName = init.name - } else if ( - (init.type === 'TSAsExpression' || init.type === 'TSTypeAssertion') && - t.isIdentifier(init.expression) - ) { - nextName = init.expression.name - } - if (!nextName) return false - if (nextName === defaultLocal) return true - if (visited.has(nextName)) return false - visited.add(nextName) - current = nextName - } -} - -// With only a styled `require` in the file, the default name is the required -// variable. A global `styled` (a bundler-provided identifier) keeps resolving -// as the default too. -const globalStyledAlias = (state, defaultLocal) => - state.styledRequired && defaultLocal === state.styledRequired - ? 'styled' - : null - -// Named exports that are never styled tags. A required binding is the module -// object (v5, v6) or the callable `styled` with these attached (v7), so -// `sc.isStyledComponent(x)` must not be decorated. Helper names are included -// because isHelper only matches identifier keys, and `sc.default.css` / -// `sc['css']` are member expressions. Intrinsic tags (div, View, and the rest) -// are not in this set. -const NON_TAG_EXPORTS = new Set([ +// Members of `import styled` / `import { styled }` that are real functions +// without `.withConfig`. Unknown names stay decorated: on v7 that binding is +// a proxy, so `styled.notAnExport` is a tag factory. Require bindings, +// namespace imports, and every other local name use the tag allowlist instead, +// which does not need this set. +const OPEN_SKIP = new Set([ + 'constructor', + 'hasOwnProperty', + 'isPrototypeOf', + 'propertyIsEnumerable', + 'toLocaleString', + 'toString', + 'valueOf', 'NativeStyleContext', 'ParentContext', 'SafeAreaInsetsProvider', @@ -202,85 +160,239 @@ const NON_TAG_EXPORTS = new Set([ 'withTheme', ]) -// Static member key: `sc.div` and `sc['div']`. A computed non-literal has none. -const memberPropertyName = (t, property) => { - if (t.isIdentifier(property)) return property.name - if (t.isStringLiteral(property)) return property.value +const INTEROP_REQUIRE = new Set(['_interopRequireDefault', '__importDefault']) + +// `sc.div` / `sc['div']` have a static name. `sc[name]` is dynamic: the +// identifier is the variable, not the property, so its spelling is not a key. +const memberKey = (t, member) => { + if (!t.isMemberExpression(member)) return null + if (member.computed) { + return t.isStringLiteral(member.property) + ? { kind: 'static', name: member.property.value } + : { kind: 'dynamic' } + } + return t.isIdentifier(member.property) + ? { kind: 'static', name: member.property.name } + : null +} + +const unwrapType = node => { + let current = node + while ( + current && + (current.type === 'TSAsExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSSatisfiesExpression') + ) { + current = current.expression + } + return current +} + +const isStyledRequireCall = (t, node, state) => { + if (!t.isCallExpression(node) || !t.isIdentifier(node.callee)) return false + if ( + INTEROP_REQUIRE.has(node.callee.name) && + node.arguments && + node.arguments[0] + ) { + return isStyledRequireCall(t, node.arguments[0], state) + } + const arg = node.arguments && node.arguments[0] + return ( + node.callee.name === 'require' && + !!arg && + t.isStringLiteral(arg) && + isValidTopLevelImport(arg.value, state) + ) +} + +// Identifier bound with `require('styled-components')` or `.default` / `['default']` +// of that call (including the `_interopRequireDefault` / `__importDefault` wrap). +export const requireBindingName = (t, id, init, state) => { + if (!t.isIdentifier(id)) return null + let node = unwrapType(init) + if (!node) return null + const peeled = staticDefaultObject(t, node) + if (peeled) node = peeled + return isStyledRequireCall(t, node, state) ? id.name : null +} + +// `.default` / `['default']`, or null when this member is not that key. +const staticDefaultObject = (t, node) => { + if (!t.isMemberExpression(node)) return null + const key = memberKey(t, node) + if (!key || key.kind !== 'static' || key.name !== 'default') return null + return node.object +} + +const importedNameOf = (t, specifier) => { + const imported = specifier.imported + if (t.isIdentifier(imported)) return imported.name + if (t.isStringLiteral(imported)) return imported.value return null } -// `ns.default`: the callable default export reached through a namespace -const isNamespaceDefault = (t, node, defaultLocal, state) => - t.isMemberExpression(node) && - node.property.name === 'default' && - (node.object.name === defaultLocal || - node.object.name === globalStyledAlias(state, defaultLocal) || - importNamespaceNames(state).includes(node.object.name)) +// 'open': default or named `styled` whose local name is `styled`. Any member +// except helpers, object methods, and known non-tag exports. +// 'allow': require() (and its `.default`), or an import with another local +// name. Intrinsic tags and styled() calls only. +// 'namespace': `import * as ns`. Same members as 'allow'. The call `ns(Comp)` +// is decorated too: a CommonJS transform binds `ns` to the module exports, +// which is the styled function. A native ESM namespace is not callable, so +// that call throws with or without `.withConfig`. +// null: this name is not a styled binding in `scope`. +const styledBindingKind = (name, scope, state, t, seen) => { + if (!name || !scope) return null + const token = `${scope.uid}:${name}` + if (seen && seen.has(token)) return null + + const binding = scope.getBinding(name) + if (!binding) { + if (state.customImportName && name === state.customImportName.name) { + return 'open' + } + // css-prop injects `var _styled = interop(require()).default` and then + // calls it. That binding is not visible to scope.getBinding, so the name + // registered for the injection is the styled function. + if (injectedDefaultName(state) === name) return 'open' + // A bundler global named `styled` next to a require() of the package. + if (name === 'styled' && state.styledRequired) return 'open' + return null + } + + // A reassigned `let` / `var` is not the original module for its whole scope. + if (binding.constant === false) return null -export const isStyled = t => (tag, state) => { + const bpath = binding.path if ( - t.isCallExpression(tag) && - t.isMemberExpression(tag.callee) && - tag.callee.property.name !== 'default' + bpath.isImportDefaultSpecifier() || + bpath.isImportSpecifier() || + bpath.isImportNamespaceSpecifier() + ) { + const decl = bpath.parentPath + if (!decl.isImportDeclaration()) return null + if (isTypeOnly(decl.node) || isTypeOnly(bpath.node)) return null + if (!isValidTopLevelImport(decl.node.source.value, state)) return null + if (bpath.isImportNamespaceSpecifier()) return 'namespace' + if (bpath.isImportSpecifier()) { + const importedName = importedNameOf(t, bpath.node) + if (importedName !== 'styled' && importedName !== 'default') return null + } + return bpath.node.local.name === 'styled' ? 'open' : 'allow' + } + + if (!bpath.isVariableDeclarator() || !t.isIdentifier(bpath.node.id)) { + return null + } + + const init = unwrapType(bpath.node.init) + if (!init) return null + // Noted only once assignStyledRequired has walked the declarator, so a + // require that sits below the first css prop is not numbered in that + // pre-pass. `.default` / `['default']` of the call is the same binding. + const peeled = staticDefaultObject(t, init) + if ( + isStyledRequireCall(t, init, state) || + (peeled && isStyledRequireCall(t, peeled, state)) ) { - return isStyled(t)(tag.callee.object, state) + return styledRequireVisited(state, bpath.node.id.name) ? 'allow' : null + } + + const chain = seen || new Set() + chain.add(token) + + if (peeled) { + // `const styled = sc.default` where `sc` is a require or a namespace. + if (t.isIdentifier(peeled)) { + const baseKind = styledBindingKind( + peeled.name, + binding.scope, + state, + t, + chain + ) + if (baseKind === 'allow' || baseKind === 'namespace') return 'allow' + } + return null + } + + if (t.isIdentifier(init)) { + return styledBindingKind(init.name, binding.scope, state, t, chain) + } + + return null +} + +const decorateKeyedMember = (kind, key, property, state, t) => { + if (!kind || !key) return false + if (key.kind === 'dynamic') return !isHelper(t)(property, state) + if (key.kind !== 'static' || key.name === 'default') return false + if (kind === 'open') { + return !OPEN_SKIP.has(key.name) && !isHelper(t)(property, state) + } + return isStyledTag(key.name) +} + +const isDefaultMember = (t, node) => { + if (!t.isMemberExpression(node) || !t.isIdentifier(node.object)) return false + const key = memberKey(t, node) + return !!key && key.kind === 'static' && key.name === 'default' +} + +export const isStyled = t => (tag, state, scope) => { + const lookup = + scope || (state.file && state.file.path && state.file.path.scope) + + if (t.isCallExpression(tag) && t.isMemberExpression(tag.callee)) { + const key = memberKey(t, tag.callee) + // `.attrs()` / `.withConfig()` chains. `['default']` is the styled + // function, same as `.default`, so it is not peeled off here. + if (!(key && key.kind === 'static' && key.name === 'default')) { + return isStyled(t)(tag.callee.object, state, lookup) + } } else if ( t.isCallExpression(tag) && t.isSequenceExpression(tag.callee) && - t.isMemberExpression(getSequenceExpressionValue(tag.callee)) && - getSequenceExpressionValue(tag.callee).property.name !== 'default' + t.isMemberExpression(getSequenceExpressionValue(tag.callee)) ) { - return isStyled(t)(getSequenceExpressionValue(tag.callee), state) - } else { - const defaultLocal = importLocalName('default', state) - const alias = globalStyledAlias(state, defaultLocal) - const matchesDefault = name => - resolvesToDefaultLocal(name, defaultLocal, state, t) || - resolvesToDefaultLocal(name, alias, state, t) - return ( - (t.isMemberExpression(tag) && - tag.property.name !== 'default' && - !NON_TAG_EXPORTS.has(memberPropertyName(t, tag.property)) && - matchesDefault(tag.object.name) && - !isHelper(t)(tag.property, state)) || - (t.isCallExpression(tag) && matchesDefault(tag.callee.name)) || - (t.isCallExpression(tag) && - t.isSequenceExpression(tag.callee) && - matchesDefault(getSequenceExpressionValue(tag.callee).name)) || - // styled.default.div``, styled.default.something() — require() forms - (state.styledRequired && - t.isMemberExpression(tag) && - t.isMemberExpression(tag.object) && - tag.object.property.name === 'default' && - tag.object.object.name === state.styledRequired && - !NON_TAG_EXPORTS.has(memberPropertyName(t, tag.property))) || - (state.styledRequired && - t.isCallExpression(tag) && - t.isMemberExpression(tag.callee) && - tag.callee.property.name === 'default' && - tag.callee.object.name === state.styledRequired) || - (state.styledRequired && - t.isCallExpression(tag) && - t.isSequenceExpression(tag.callee) && - t.isMemberExpression(getSequenceExpressionValue(tag.callee)) && - getSequenceExpressionValue(tag.callee).property.name === 'default' && - getSequenceExpressionValue(tag.callee).object.name === - state.styledRequired) || - (t.isMemberExpression(tag) && - isNamespaceDefault(t, tag.object, defaultLocal, state) && - !NON_TAG_EXPORTS.has(memberPropertyName(t, tag.property))) || - (t.isCallExpression(tag) && - isNamespaceDefault(t, tag.callee, defaultLocal, state)) || - (t.isCallExpression(tag) && - t.isSequenceExpression(tag.callee) && - isNamespaceDefault( - t, - getSequenceExpressionValue(tag.callee), - defaultLocal, - state - )) - ) + const value = getSequenceExpressionValue(tag.callee) + const key = memberKey(t, value) + if (!(key && key.kind === 'static' && key.name === 'default')) { + return isStyled(t)(value, state, lookup) + } + } + + if (t.isCallExpression(tag)) { + const callee = t.isSequenceExpression(tag.callee) + ? getSequenceExpressionValue(tag.callee) + : tag.callee + if (t.isIdentifier(callee)) { + const kind = styledBindingKind(callee.name, lookup, state, t) + return kind === 'open' || kind === 'allow' || kind === 'namespace' + } + if (isDefaultMember(t, callee)) { + const kind = styledBindingKind(callee.object.name, lookup, state, t) + return kind === 'open' || kind === 'allow' || kind === 'namespace' + } + return false } + + if (!t.isMemberExpression(tag)) return false + + if (isDefaultMember(t, tag.object) && t.isIdentifier(tag.object.object)) { + const kind = styledBindingKind(tag.object.object.name, lookup, state, t) + if (!kind) return false + const outer = memberKey(t, tag) + // `sc.default[name]` is a runtime lookup. The require path has always + // decorated it, including when the variable is spelled like a helper. + if (outer && outer.kind === 'dynamic') return true + return decorateKeyedMember(kind, outer, tag.property, state, t) + } + + if (!t.isIdentifier(tag.object)) return false + const kind = styledBindingKind(tag.object.name, lookup, state, t) + return decorateKeyedMember(kind, memberKey(t, tag), tag.property, state, t) } export const isCSSHelper = t => (tag, state) => diff --git a/src/utils/domElements.js b/src/utils/domElements.js new file mode 100644 index 00000000..e381f5c2 --- /dev/null +++ b/src/utils/domElements.js @@ -0,0 +1,200 @@ +// Intrinsic tags styled-components attaches to the styled function. Copied +// here so the plugin does not import the runtime. The DOM list is the union +// of the v7 `elements` table and the v5 names that table dropped (`base`, +// `marquee`, and the rest). The React Native aliases are the `styled` +// shortcuts from `styled-components/native`. +const STYLED_TAGS = new Set([ + 'a', + 'abbr', + 'address', + 'area', + 'article', + 'aside', + 'audio', + 'b', + 'base', + 'bdi', + 'bdo', + 'big', + 'blockquote', + 'body', + 'br', + 'button', + 'canvas', + 'caption', + 'cite', + 'code', + 'col', + 'colgroup', + 'data', + 'datalist', + 'dd', + 'del', + 'details', + 'dfn', + 'dialog', + 'div', + 'dl', + 'dt', + 'em', + 'embed', + 'fieldset', + 'figcaption', + 'figure', + 'footer', + 'form', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hgroup', + 'hr', + 'html', + 'i', + 'iframe', + 'img', + 'input', + 'ins', + 'kbd', + 'keygen', + 'label', + 'legend', + 'li', + 'link', + 'main', + 'map', + 'mark', + 'marquee', + 'menu', + 'menuitem', + 'meta', + 'meter', + 'nav', + 'noscript', + 'object', + 'ol', + 'optgroup', + 'option', + 'output', + 'p', + 'param', + 'picture', + 'pre', + 'progress', + 'q', + 'rp', + 'rt', + 'ruby', + 's', + 'samp', + 'script', + 'search', + 'section', + 'select', + 'slot', + 'small', + 'source', + 'span', + 'strong', + 'style', + 'sub', + 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'template', + 'textarea', + 'tfoot', + 'th', + 'thead', + 'time', + 'title', + 'tr', + 'track', + 'u', + 'ul', + 'var', + 'video', + 'wbr', + 'circle', + 'clipPath', + 'defs', + 'ellipse', + 'feBlend', + 'feColorMatrix', + 'feComponentTransfer', + 'feComposite', + 'feConvolveMatrix', + 'feDiffuseLighting', + 'feDisplacementMap', + 'feDistantLight', + 'feDropShadow', + 'feFlood', + 'feFuncA', + 'feFuncB', + 'feFuncG', + 'feFuncR', + 'feGaussianBlur', + 'feImage', + 'feMerge', + 'feMergeNode', + 'feMorphology', + 'feOffset', + 'fePointLight', + 'feSpecularLighting', + 'feSpotLight', + 'feTile', + 'feTurbulence', + 'filter', + 'foreignObject', + 'g', + 'image', + 'line', + 'linearGradient', + 'marker', + 'mask', + 'path', + 'pattern', + 'polygon', + 'polyline', + 'radialGradient', + 'rect', + 'stop', + 'svg', + 'switch', + 'symbol', + 'text', + 'textPath', + 'tspan', + 'use', + 'ActivityIndicator', + 'Button', + 'FlatList', + 'Image', + 'ImageBackground', + 'InputAccessoryView', + 'KeyboardAvoidingView', + 'Modal', + 'Pressable', + 'RefreshControl', + 'SafeAreaView', + 'ScrollView', + 'SectionList', + 'StatusBar', + 'Switch', + 'Text', + 'TextInput', + 'TouchableHighlight', + 'TouchableNativeFeedback', + 'TouchableOpacity', + 'TouchableWithoutFeedback', + 'View', + 'VirtualizedList', +]) + +export const isStyledTag = name => STYLED_TAGS.has(name) diff --git a/src/visitors/assignStyledRequired.js b/src/visitors/assignStyledRequired.js index 2af37b40..3c158c4a 100644 --- a/src/visitors/assignStyledRequired.js +++ b/src/visitors/assignStyledRequired.js @@ -1,30 +1,8 @@ -import { isValidTopLevelImport } from '../utils/detectors' +import { noteStyledRequired, requireBindingName } from '../utils/detectors' export default t => (path, state) => { - let init = path.node.init - - if ( - t.isCallExpression(path.node.init) && - t.isIdentifier(path.node.init.callee) && - // `_interopRequireDefault` is Babel's CJS interop helper; `__importDefault` - // is TypeScript's. Both wrap a require() so the caller can reach `.default`. - (init.callee.name === '_interopRequireDefault' || - init.callee.name === '__importDefault') && - init.arguments && - init.arguments[0] - ) { - init = path.node.init.arguments[0] - } - - if ( - t.isCallExpression(init) && - t.isIdentifier(init.callee) && - init.callee.name === 'require' && - init.arguments && - init.arguments[0] && - t.isStringLiteral(init.arguments[0]) && - isValidTopLevelImport(init.arguments[0].value, state) - ) { - state.styledRequired = path.node.id.name - } + const name = requireBindingName(t, path.node.id, path.node.init, state) + if (!name) return + state.styledRequired = name + noteStyledRequired(state, name) } diff --git a/src/visitors/displayNameAndId.js b/src/visitors/displayNameAndId.js index 8b59aeab..7f282911 100644 --- a/src/visitors/displayNameAndId.js +++ b/src/visitors/displayNameAndId.js @@ -232,8 +232,12 @@ const calleeNeedsConfig = (t, callee) => { // styled(x)({}) if (!t.isMemberExpression(callee.callee)) return true - const method = callee.callee.property && callee.callee.property.name - if (!method) return false + const property = callee.callee.property + // `styled['default'](x)({})` has a string key, so `.name` is empty. + const method = + property && + (property.name || (t.isStringLiteral(property) ? property.value : null)) + if (!method) return true // styled(x).attrs()({}) if (method !== 'withConfig') return true @@ -253,8 +257,8 @@ export default t => { const { tag, callee } = path.node const needsConfig = tag - ? isStyledNode(tag, state) - : isStyledNode(callee, state) && calleeNeedsConfig(t, callee) + ? isStyledNode(tag, state, path.scope) + : isStyledNode(callee, state, path.scope) && calleeNeedsConfig(t, callee) if (!needsConfig) return diff --git a/src/visitors/minify.js b/src/visitors/minify.js index b0140b60..e80b3688 100644 --- a/src/visitors/minify.js +++ b/src/visitors/minify.js @@ -5,7 +5,8 @@ import { minifyRawValues, minifyCookedValues } from '../minify' export default t => (path, state) => { if ( useMinify(state) && - (isStyled(t)(path.node.tag, state) || isHelper(t)(path.node.tag, state)) + (isStyled(t)(path.node.tag, state, path.scope) || + isHelper(t)(path.node.tag, state)) ) { const templateLiteral = path.node.quasi const quasisLength = templateLiteral.quasis.length diff --git a/src/visitors/pure.js b/src/visitors/pure.js index 8c9626d9..6ddcd86c 100644 --- a/src/visitors/pure.js +++ b/src/visitors/pure.js @@ -6,8 +6,8 @@ import { isStyled, isPureHelper } from '../utils/detectors' export default t => (path, state) => { if (usePureAnnotation(state)) { if ( - isStyled(t)(path.node, state) || - isStyled(t)(path.node.callee, state) || + isStyled(t)(path.node, state, path.scope) || + isStyled(t)(path.node.callee, state, path.scope) || isPureHelper(t)(path.node.tag || path.node.callee, state) ) { if ( diff --git a/src/visitors/templateLiterals/transpile.js b/src/visitors/templateLiterals/transpile.js index a7716673..a1801895 100644 --- a/src/visitors/templateLiterals/transpile.js +++ b/src/visitors/templateLiterals/transpile.js @@ -1,7 +1,10 @@ import { isStyled, isHelper } from '../../utils/detectors' export default t => (path, state) => { - if (isStyled(t)(path.node.tag, state) || isHelper(t)(path.node.tag, state)) { + if ( + isStyled(t)(path.node.tag, state, path.scope) || + isHelper(t)(path.node.tag, state) + ) { const { tag: callee, quasi: { quasis, expressions }, diff --git a/test/fixtures/aliased-import-allowlist/.babelrc b/test/fixtures/aliased-import-allowlist/.babelrc new file mode 100644 index 00000000..4f8903d2 --- /dev/null +++ b/test/fixtures/aliased-import-allowlist/.babelrc @@ -0,0 +1,14 @@ +{ + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/aliased-import-allowlist/code.js b/test/fixtures/aliased-import-allowlist/code.js new file mode 100644 index 00000000..a7ffc5b7 --- /dev/null +++ b/test/fixtures/aliased-import-allowlist/code.js @@ -0,0 +1,8 @@ +import sc from 'styled-components' +import { styled as s } from 'styled-components' + +const FromDefault = sc.div`color: red;` +const FromNamed = s.View`color: blue;` +const helper = sc.isStyledComponent(FromDefault) +const unknown = s.somethingNew`color: green;` +const owned = sc.hasOwnProperty('div') diff --git a/test/fixtures/aliased-import-allowlist/output.js b/test/fixtures/aliased-import-allowlist/output.js new file mode 100644 index 00000000..5ff95e74 --- /dev/null +++ b/test/fixtures/aliased-import-allowlist/output.js @@ -0,0 +1,13 @@ +import sc from 'styled-components'; +import { styled as s } from 'styled-components'; +const FromDefault = sc.div.withConfig({ + displayName: "code__FromDefault", + componentId: "sc-ijl2tq-0" +})(["color: red;"]); +const FromNamed = s.View.withConfig({ + displayName: "code__FromNamed", + componentId: "sc-ijl2tq-1" +})(["color: blue;"]); +const helper = sc.isStyledComponent(FromDefault); +const unknown = s.somethingNew`color: green;`; +const owned = sc.hasOwnProperty('div'); diff --git a/test/fixtures/css-prop-script-mode-require-default-after-css-prop/.babelrc b/test/fixtures/css-prop-script-mode-require-default-after-css-prop/.babelrc new file mode 100644 index 00000000..e7cd3222 --- /dev/null +++ b/test/fixtures/css-prop-script-mode-require-default-after-css-prop/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "cssProp": true + } + ] + ] +} diff --git a/test/fixtures/css-prop-script-mode-require-default-after-css-prop/code.js b/test/fixtures/css-prop-script-mode-require-default-after-css-prop/code.js new file mode 100644 index 00000000..5d891f13 --- /dev/null +++ b/test/fixtures/css-prop-script-mode-require-default-after-css-prop/code.js @@ -0,0 +1,7 @@ +const styled = require('styled-components').default + +const Early = styled.h1`color: blue;` +const A = () =>
+ +const late = require('styled-components')['default'] +const Late = late.span`color: green;` diff --git a/test/fixtures/css-prop-script-mode-require-default-after-css-prop/output.js b/test/fixtures/css-prop-script-mode-require-default-after-css-prop/output.js new file mode 100644 index 00000000..0d0c0581 --- /dev/null +++ b/test/fixtures/css-prop-script-mode-require-default-after-css-prop/output.js @@ -0,0 +1,17 @@ +var _styled = _interopRequireDefault(require("styled-components")).default; +function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } +const styled = require('styled-components').default; +const Early = styled.h1.withConfig({ + displayName: "code__Early", + componentId: "sc-lpytol-0" +})(["color:blue;"]); +const A = () => <_StyledDiv />; +const late = require('styled-components')['default']; +const Late = late.span.withConfig({ + displayName: "code__Late", + componentId: "sc-lpytol-2" +})(["color:green;"]); +var _StyledDiv = _styled("div").withConfig({ + displayName: "code___StyledDiv", + componentId: "sc-lpytol-1" +})(["color:red;"]); diff --git a/test/fixtures/css-prop-with-named-styled-import/.babelrc b/test/fixtures/css-prop-with-named-styled-import/.babelrc new file mode 100644 index 00000000..c8dd2643 --- /dev/null +++ b/test/fixtures/css-prop-with-named-styled-import/.babelrc @@ -0,0 +1,15 @@ +{ + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false, + "cssProp": true + } + ] + ] +} diff --git a/test/fixtures/css-prop-with-named-styled-import/code.js b/test/fixtures/css-prop-with-named-styled-import/code.js new file mode 100644 index 00000000..67ac77ae --- /dev/null +++ b/test/fixtures/css-prop-with-named-styled-import/code.js @@ -0,0 +1,4 @@ +import { styled } from 'styled-components' + +const Tag = styled.div`color: blue;` +const App = () =>
diff --git a/test/fixtures/css-prop-with-named-styled-import/output.js b/test/fixtures/css-prop-with-named-styled-import/output.js new file mode 100644 index 00000000..e5dc8a7e --- /dev/null +++ b/test/fixtures/css-prop-with-named-styled-import/output.js @@ -0,0 +1,10 @@ +import { styled } from 'styled-components'; +const Tag = styled.div.withConfig({ + displayName: "code__Tag", + componentId: "sc-sqpadk-0" +})(["color: blue;"]); +const App = () => <_StyledDiv />; +var _StyledDiv = styled("div").withConfig({ + displayName: "code___StyledDiv", + componentId: "sc-sqpadk-1" +})(["color: red;"]); diff --git a/test/fixtures/default-import-unknown-member/.babelrc b/test/fixtures/default-import-unknown-member/.babelrc new file mode 100644 index 00000000..4f8903d2 --- /dev/null +++ b/test/fixtures/default-import-unknown-member/.babelrc @@ -0,0 +1,14 @@ +{ + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/default-import-unknown-member/code.js b/test/fixtures/default-import-unknown-member/code.js new file mode 100644 index 00000000..e5d42b1b --- /dev/null +++ b/test/fixtures/default-import-unknown-member/code.js @@ -0,0 +1,6 @@ +import styled from 'styled-components' + +const Tag = styled.div`color: red;` +const Extra = styled.notAnExport`color: blue;` +const helper = styled.isStyledComponent(Tag) +const owned = styled.hasOwnProperty('div') diff --git a/test/fixtures/default-import-unknown-member/output.js b/test/fixtures/default-import-unknown-member/output.js new file mode 100644 index 00000000..bf7d1fe5 --- /dev/null +++ b/test/fixtures/default-import-unknown-member/output.js @@ -0,0 +1,11 @@ +import styled from 'styled-components'; +const Tag = styled.div.withConfig({ + displayName: "code__Tag", + componentId: "sc-1awywoz-0" +})(["color: red;"]); +const Extra = styled.notAnExport.withConfig({ + displayName: "code__Extra", + componentId: "sc-1awywoz-1" +})(["color: blue;"]); +const helper = styled.isStyledComponent(Tag); +const owned = styled.hasOwnProperty('div'); diff --git a/test/fixtures/namespace-direct-call/.babelrc b/test/fixtures/namespace-direct-call/.babelrc new file mode 100644 index 00000000..4f8903d2 --- /dev/null +++ b/test/fixtures/namespace-direct-call/.babelrc @@ -0,0 +1,14 @@ +{ + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/namespace-direct-call/code.js b/test/fixtures/namespace-direct-call/code.js new file mode 100644 index 00000000..04ebac39 --- /dev/null +++ b/test/fixtures/namespace-direct-call/code.js @@ -0,0 +1,13 @@ +import * as ns from 'styled-components' + +function Base(props) { + return props.children || null +} + +const Called = ns(Base)`color: red;` +const ViaComputed = ns['default'](Base)`color: blue;` +const Tag = ns.div`color: green;` +const helper = ns.isStyledComponent(Tag) +const renamed = ns.default +const Aliased = renamed.span`color: purple;` +const unknown = renamed.somethingNew`color: black;` diff --git a/test/fixtures/namespace-direct-call/output.js b/test/fixtures/namespace-direct-call/output.js new file mode 100644 index 00000000..1cff3b6c --- /dev/null +++ b/test/fixtures/namespace-direct-call/output.js @@ -0,0 +1,23 @@ +import * as ns from 'styled-components'; +function Base(props) { + return props.children || null; +} +const Called = ns(Base).withConfig({ + displayName: "code__Called", + componentId: "sc-p2vwnu-0" +})(["color: red;"]); +const ViaComputed = ns['default'](Base).withConfig({ + displayName: "code__ViaComputed", + componentId: "sc-p2vwnu-1" +})(["color: blue;"]); +const Tag = ns.div.withConfig({ + displayName: "code__Tag", + componentId: "sc-p2vwnu-2" +})(["color: green;"]); +const helper = ns.isStyledComponent(Tag); +const renamed = ns.default; +const Aliased = renamed.span.withConfig({ + displayName: "code__Aliased", + componentId: "sc-p2vwnu-3" +})(["color: purple;"]); +const unknown = renamed.somethingNew`color: black;`; diff --git a/test/fixtures/require-computed-default/.babelrc b/test/fixtures/require-computed-default/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-computed-default/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-computed-default/code.js b/test/fixtures/require-computed-default/code.js new file mode 100644 index 00000000..1d5d7d36 --- /dev/null +++ b/test/fixtures/require-computed-default/code.js @@ -0,0 +1,9 @@ +const sc = require('styled-components') + +function Base(props) { + return props.children || null +} + +const Tagged = sc['default'].div`color: red;` +const Called = sc['default'](Base)`color: blue;` +const ObjectCalled = sc['default'](Base)({ color: 'green' }) diff --git a/test/fixtures/require-computed-default/output.js b/test/fixtures/require-computed-default/output.js new file mode 100644 index 00000000..b0f8f81b --- /dev/null +++ b/test/fixtures/require-computed-default/output.js @@ -0,0 +1,18 @@ +const sc = require('styled-components'); +function Base(props) { + return props.children || null; +} +const Tagged = sc['default'].div.withConfig({ + displayName: "code__Tagged", + componentId: "sc-nrtf10-0" +})(["color: red;"]); +const Called = sc['default'](Base).withConfig({ + displayName: "code__Called", + componentId: "sc-nrtf10-1" +})(["color: blue;"]); +const ObjectCalled = sc['default'](Base).withConfig({ + displayName: "code__ObjectCalled", + componentId: "sc-nrtf10-2" +})({ + color: 'green' +}); diff --git a/test/fixtures/require-computed-variable-key/.babelrc b/test/fixtures/require-computed-variable-key/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-computed-variable-key/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-computed-variable-key/code.js b/test/fixtures/require-computed-variable-key/code.js new file mode 100644 index 00000000..07af4df2 --- /dev/null +++ b/test/fixtures/require-computed-variable-key/code.js @@ -0,0 +1,4 @@ +const sc = require('styled-components') +const css = 'div' + +const Comp = sc.default[css]`color: red;` diff --git a/test/fixtures/require-computed-variable-key/output.js b/test/fixtures/require-computed-variable-key/output.js new file mode 100644 index 00000000..6742fdab --- /dev/null +++ b/test/fixtures/require-computed-variable-key/output.js @@ -0,0 +1,6 @@ +const sc = require('styled-components'); +const css = 'div'; +const Comp = sc.default[css].withConfig({ + displayName: "code__Comp", + componentId: "sc-wgr5zl-0" +})(["color: red;"]); diff --git a/test/fixtures/require-default-alias/.babelrc b/test/fixtures/require-default-alias/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-default-alias/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-default-alias/code.js b/test/fixtures/require-default-alias/code.js new file mode 100644 index 00000000..7c224c5a --- /dev/null +++ b/test/fixtures/require-default-alias/code.js @@ -0,0 +1,6 @@ +const sc = require('styled-components') +const styled = sc.default + +const Tag = styled.div`color: red;` +const helper = styled.isStyledComponent(Tag) +const unknown = styled.somethingNew`color: blue;` diff --git a/test/fixtures/require-default-alias/output.js b/test/fixtures/require-default-alias/output.js new file mode 100644 index 00000000..b3d1b5c2 --- /dev/null +++ b/test/fixtures/require-default-alias/output.js @@ -0,0 +1,8 @@ +const sc = require('styled-components'); +const styled = sc.default; +const Tag = styled.div.withConfig({ + displayName: "code__Tag", + componentId: "sc-oqdthy-0" +})(["color: red;"]); +const helper = styled.isStyledComponent(Tag); +const unknown = styled.somethingNew`color: blue;`; diff --git a/test/fixtures/require-default-binding/.babelrc b/test/fixtures/require-default-binding/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-default-binding/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-default-binding/code.js b/test/fixtures/require-default-binding/code.js new file mode 100644 index 00000000..c0778400 --- /dev/null +++ b/test/fixtures/require-default-binding/code.js @@ -0,0 +1,11 @@ +const styled = require('styled-components').default +const viaComputed = require('styled-components')['default'] + +function Base(props) { + return props.children || null +} + +const Tag = styled.div`color: red;` +const Called = viaComputed(Base)`color: blue;` +const helper = styled.isStyledComponent(Tag) +const unknown = styled.somethingNew`color: green;` diff --git a/test/fixtures/require-default-binding/output.js b/test/fixtures/require-default-binding/output.js new file mode 100644 index 00000000..78431bbe --- /dev/null +++ b/test/fixtures/require-default-binding/output.js @@ -0,0 +1,15 @@ +const styled = require('styled-components').default; +const viaComputed = require('styled-components')['default']; +function Base(props) { + return props.children || null; +} +const Tag = styled.div.withConfig({ + displayName: "code__Tag", + componentId: "sc-1odpxj7-0" +})(["color: red;"]); +const Called = viaComputed(Base).withConfig({ + displayName: "code__Called", + componentId: "sc-1odpxj7-1" +})(["color: blue;"]); +const helper = styled.isStyledComponent(Tag); +const unknown = styled.somethingNew`color: green;`; diff --git a/test/fixtures/require-object-methods/.babelrc b/test/fixtures/require-object-methods/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-object-methods/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-object-methods/code.js b/test/fixtures/require-object-methods/code.js new file mode 100644 index 00000000..364b3735 --- /dev/null +++ b/test/fixtures/require-object-methods/code.js @@ -0,0 +1,5 @@ +const sc = require('styled-components') + +const Comp = sc.default.div`color: red;` +const has = sc.hasOwnProperty('css') +const text = String(sc.toString()) diff --git a/test/fixtures/require-object-methods/output.js b/test/fixtures/require-object-methods/output.js new file mode 100644 index 00000000..35e90bd9 --- /dev/null +++ b/test/fixtures/require-object-methods/output.js @@ -0,0 +1,7 @@ +const sc = require('styled-components'); +const Comp = sc.default.div.withConfig({ + displayName: "code__Comp", + componentId: "sc-n5x1im-0" +})(["color: red;"]); +const has = sc.hasOwnProperty('css'); +const text = String(sc.toString()); diff --git a/test/fixtures/require-reassign/.babelrc b/test/fixtures/require-reassign/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-reassign/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-reassign/code.js b/test/fixtures/require-reassign/code.js new file mode 100644 index 00000000..2208d172 --- /dev/null +++ b/test/fixtures/require-reassign/code.js @@ -0,0 +1,7 @@ +const real = require('styled-components') +let sc = require('styled-components') + +sc = { div: s => () => null } + +const Comp = sc.div`color: red;` +const Real = real.default.div`color: blue;` diff --git a/test/fixtures/require-reassign/output.js b/test/fixtures/require-reassign/output.js new file mode 100644 index 00000000..f35a4b4c --- /dev/null +++ b/test/fixtures/require-reassign/output.js @@ -0,0 +1,10 @@ +const real = require('styled-components'); +let sc = require('styled-components'); +sc = { + div: s => () => null +}; +const Comp = sc.div`color: red;`; +const Real = real.default.div.withConfig({ + displayName: "code__Real", + componentId: "sc-1awls2t-0" +})(["color: blue;"]); diff --git a/test/fixtures/require-shadow-local/.babelrc b/test/fixtures/require-shadow-local/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-shadow-local/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-shadow-local/code.js b/test/fixtures/require-shadow-local/code.js new file mode 100644 index 00000000..11b7ab0a --- /dev/null +++ b/test/fixtures/require-shadow-local/code.js @@ -0,0 +1,15 @@ +const sc = require('styled-components') + +function make() { + const sc = { div: s => () => null } + return sc.div`color: red;` +} + +function other() { + const sc = { box: s => () => null } + return sc.box`color: blue;` +} + +const Comp = make() +const Other = other() +const Real = sc.default.div`color: green;` diff --git a/test/fixtures/require-shadow-local/output.js b/test/fixtures/require-shadow-local/output.js new file mode 100644 index 00000000..750e8184 --- /dev/null +++ b/test/fixtures/require-shadow-local/output.js @@ -0,0 +1,19 @@ +const sc = require('styled-components'); +function make() { + const sc = { + div: s => () => null + }; + return sc.div`color: red;`; +} +function other() { + const sc = { + box: s => () => null + }; + return sc.box`color: blue;`; +} +const Comp = make(); +const Other = other(); +const Real = sc.default.div.withConfig({ + displayName: "code__Real", + componentId: "sc-1tv6n6c-0" +})(["color: green;"]); diff --git a/test/fixtures/require-shadow-param/.babelrc b/test/fixtures/require-shadow-param/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-shadow-param/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-shadow-param/code.js b/test/fixtures/require-shadow-param/code.js new file mode 100644 index 00000000..d1139971 --- /dev/null +++ b/test/fixtures/require-shadow-param/code.js @@ -0,0 +1,8 @@ +const sc = require('styled-components') + +function make(sc) { + return sc.div`color: red;` +} + +const Comp = make({ div: s => () => null }) +const Real = sc.default.div`color: blue;` diff --git a/test/fixtures/require-shadow-param/output.js b/test/fixtures/require-shadow-param/output.js new file mode 100644 index 00000000..44e2e34c --- /dev/null +++ b/test/fixtures/require-shadow-param/output.js @@ -0,0 +1,11 @@ +const sc = require('styled-components'); +function make(sc) { + return sc.div`color: red;`; +} +const Comp = make({ + div: s => () => null +}); +const Real = sc.default.div.withConfig({ + displayName: "code__Real", + componentId: "sc-1xotko2-0" +})(["color: blue;"]); diff --git a/test/fixtures/require-unknown-export/.babelrc b/test/fixtures/require-unknown-export/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-unknown-export/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-unknown-export/code.js b/test/fixtures/require-unknown-export/code.js new file mode 100644 index 00000000..29d0778b --- /dev/null +++ b/test/fixtures/require-unknown-export/code.js @@ -0,0 +1,9 @@ +const sc = require('styled-components') + +function Base(props) { + return props.children || null +} + +const Comp = sc.default.div`color: red;` +const n = typeof sc.somethingNew === 'function' ? sc.somethingNew(Base) : null +const Ghost = sc.somethingNew`color: blue;` diff --git a/test/fixtures/require-unknown-export/output.js b/test/fixtures/require-unknown-export/output.js new file mode 100644 index 00000000..c27061c1 --- /dev/null +++ b/test/fixtures/require-unknown-export/output.js @@ -0,0 +1,10 @@ +const sc = require('styled-components'); +function Base(props) { + return props.children || null; +} +const Comp = sc.default.div.withConfig({ + displayName: "code__Comp", + componentId: "sc-9r4q5l-0" +})(["color: red;"]); +const n = typeof sc.somethingNew === 'function' ? sc.somethingNew(Base) : null; +const Ghost = sc.somethingNew`color: blue;`; diff --git a/test/fixtures/require-unrelated-styled-binding/.babelrc b/test/fixtures/require-unrelated-styled-binding/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-unrelated-styled-binding/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-unrelated-styled-binding/code.js b/test/fixtures/require-unrelated-styled-binding/code.js new file mode 100644 index 00000000..9419bc3e --- /dev/null +++ b/test/fixtures/require-unrelated-styled-binding/code.js @@ -0,0 +1,5 @@ +const sc = require('styled-components') +const styled = { box: s => () => null } + +const Comp = sc.default.div`color: red;` +const Other = styled.box`color: blue;` diff --git a/test/fixtures/require-unrelated-styled-binding/output.js b/test/fixtures/require-unrelated-styled-binding/output.js new file mode 100644 index 00000000..e56ede6f --- /dev/null +++ b/test/fixtures/require-unrelated-styled-binding/output.js @@ -0,0 +1,9 @@ +const sc = require('styled-components'); +const styled = { + box: s => () => null +}; +const Comp = sc.default.div.withConfig({ + displayName: "code__Comp", + componentId: "sc-1rj7i4r-0" +})(["color: red;"]); +const Other = styled.box`color: blue;`; diff --git a/test/fixtures/string-named-styled-import/.babelrc b/test/fixtures/string-named-styled-import/.babelrc new file mode 100644 index 00000000..4f8903d2 --- /dev/null +++ b/test/fixtures/string-named-styled-import/.babelrc @@ -0,0 +1,14 @@ +{ + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/string-named-styled-import/code.js b/test/fixtures/string-named-styled-import/code.js new file mode 100644 index 00000000..0ce44cb2 --- /dev/null +++ b/test/fixtures/string-named-styled-import/code.js @@ -0,0 +1,5 @@ +import { 'styled' as s } from 'styled-components' + +const Tag = s.div`color: red;` +const helper = s.isStyledComponent(Tag) +const unknown = s.somethingNew`color: blue;` diff --git a/test/fixtures/string-named-styled-import/output.js b/test/fixtures/string-named-styled-import/output.js new file mode 100644 index 00000000..40ee4031 --- /dev/null +++ b/test/fixtures/string-named-styled-import/output.js @@ -0,0 +1,7 @@ +import { 'styled' as s } from 'styled-components'; +const Tag = s.div.withConfig({ + displayName: "code__Tag", + componentId: "sc-2s7ou2-0" +})(["color: red;"]); +const helper = s.isStyledComponent(Tag); +const unknown = s.somethingNew`color: blue;`; diff --git a/test/fixtures/styled-alias-ts-assertion/.babelrc b/test/fixtures/styled-alias-ts-assertion/.babelrc new file mode 100644 index 00000000..2349af8b --- /dev/null +++ b/test/fixtures/styled-alias-ts-assertion/.babelrc @@ -0,0 +1,17 @@ +{ + "parserOpts": { + "plugins": ["typescript"] + }, + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/styled-alias-ts-assertion/code.js b/test/fixtures/styled-alias-ts-assertion/code.js new file mode 100644 index 00000000..00964925 --- /dev/null +++ b/test/fixtures/styled-alias-ts-assertion/code.js @@ -0,0 +1,8 @@ +import baseStyled from 'styled-components' + +const styled = baseStyled as any +const satisfied = baseStyled satisfies any + +const Foo = styled.div`color: red;` +const Bar = satisfied.span`color: blue;` +const helper = styled.isStyledComponent(Foo) diff --git a/test/fixtures/styled-alias-ts-assertion/output.js b/test/fixtures/styled-alias-ts-assertion/output.js new file mode 100644 index 00000000..eb5dfa06 --- /dev/null +++ b/test/fixtures/styled-alias-ts-assertion/output.js @@ -0,0 +1,12 @@ +import baseStyled from 'styled-components'; +const styled = baseStyled as any; +const satisfied = baseStyled satisfies any; +const Foo = styled.div.withConfig({ + displayName: "code__Foo", + componentId: "sc-o52rri-0" +})(["color: red;"]); +const Bar = satisfied.span.withConfig({ + displayName: "code__Bar", + componentId: "sc-o52rri-1" +})(["color: blue;"]); +const helper = styled.isStyledComponent(Foo); From c357cb66801628e10449ee9ccd2d5fdac895b124 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Thu, 22 Oct 2026 20:54:36 +0000 Subject: [PATCH 6/7] fix(detectors): decorate unknown properties of the styled factory v7's styled factory is a proxy, so a tag allowlist dropped display names for properties such as box and feNotAFilter. Factory bindings now use open mode. Static computed helper keys stay plain, and a use written before a reassignment keeps its id. Co-authored-by: Evan Jacobs --- .changeset/require-non-tag-exports.md | 4 +- AGENTS.md | 2 +- scripts/compat.js | 90 ++++++-- src/utils/detectors.js | 186 +++++++++++----- src/utils/domElements.js | 200 ------------------ .../fixtures/aliased-import-allowlist/code.js | 5 + .../aliased-import-allowlist/output.js | 19 +- .../namespace-default-non-tag-exports/code.js | 2 + .../output.js | 8 + test/fixtures/namespace-direct-call/code.js | 2 + test/fixtures/namespace-direct-call/output.js | 10 +- .../pre-transpiled-tsc-importdefault/code.js | 6 + .../output.js | 7 + .../require-computed-static-key/.babelrc | 15 ++ .../require-computed-static-key/code.js | 17 ++ .../require-computed-static-key/output.js | 24 +++ test/fixtures/require-default-alias/output.js | 5 +- .../require-default-binding/output.js | 5 +- .../code.js | 11 + .../output.js | 23 ++ test/fixtures/require-non-tag-exports/code.js | 4 + .../require-non-tag-exports/output.js | 16 ++ .../fixtures/require-reassign-before/.babelrc | 15 ++ test/fixtures/require-reassign-before/code.js | 14 ++ .../require-reassign-before/output.js | 20 ++ .../fixtures/require-unknown-export/output.js | 10 +- .../string-named-styled-import/output.js | 5 +- 27 files changed, 446 insertions(+), 279 deletions(-) delete mode 100644 src/utils/domElements.js create mode 100644 test/fixtures/require-computed-static-key/.babelrc create mode 100644 test/fixtures/require-computed-static-key/code.js create mode 100644 test/fixtures/require-computed-static-key/output.js create mode 100644 test/fixtures/require-reassign-before/.babelrc create mode 100644 test/fixtures/require-reassign-before/code.js create mode 100644 test/fixtures/require-reassign-before/output.js diff --git a/.changeset/require-non-tag-exports.md b/.changeset/require-non-tag-exports.md index cb7dd16b..9fbcf6fe 100644 --- a/.changeset/require-non-tag-exports.md +++ b/.changeset/require-non-tag-exports.md @@ -2,6 +2,4 @@ 'babel-plugin-styled-components': patch --- -CommonJS and aliased styled-components bindings no longer treat every property as a component. `const sc = require('styled-components')` followed by `sc.isStyledComponent(A)`, `sc.hasOwnProperty('css')`, `sc.toString()`, or a property this plugin does not know (`sc.somethingNew`) was rewritten into a `.withConfig(...)` call and crashed on styled-components v5, v6, and v7. The same crash happened through `sc.default`, `sc['default']`, a namespace import, an aliased import (`import sc` or `import { styled as s }`), and a local `sc` that shadowed or replaced the require. Those are left alone now. - -Tags and real `styled()` calls still get a display name and component id, including `sc.div`, `sc['div']`, `sc.default.div`, `sc['default'].div`, `sc(Comp)`, `sc.default(Comp)`, and `sc['default'](Comp)`. A default or named import whose local name is `styled` still decorates unknown properties, so a future tag on the v7 proxy keeps working. A computed key that is a variable (`sc.default[name]`) is decorated the way it was before, instead of being mistaken for a helper. +A binding that is the styled factory keeps display names and component ids for properties this plugin does not recognize. On styled-components v7 that factory is a proxy, so `styled.box`, `styled.foo`, and `styled.feNotAFilter` are real components. `const styled = require('styled-components')`, `import sc from 'styled-components'`, `sc.default`, `ns.default`, and `__importDefault(require('styled-components')).default` are that factory. Known non-tag exports and object methods stay plain, including `sc.css`, `sc.keyframes`, `sc.createGlobalStyle`, `sc['createGlobalStyle']`, `sc.isStyledComponent`, `sc.hasOwnProperty`, `sc.toString`, and `sc.default.css`. A computed key is resolved when it is a string, a template with no interpolation, or a const initialized to one of those, so `const name = 'css'; sc[name]` and `` sc[`css`] `` stay plain. A key the plugin cannot resolve is still decorated, and that throws if the runtime value is a helper. A `let` or `var` that starts as the factory and is reassigned later keeps its display name on uses written before the reassignment. Uses written after it are left alone. diff --git a/AGENTS.md b/AGENTS.md index 73ca9df9..3aa41a55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ Review - Every behavior change is checked against styled-components v5 (latest 5.x), v6 (latest 6.x), and v7 (latest prerelease dist-tag). `node scripts/compat.js` compiles smoke programs with the branch and with main, then executes the output (`renderToString` plus `ServerStyleSheet`) against each version. Generated CSS, displayName, and componentId match main except the intended change. No runtime errors. - Module formats: the plugin loads from CJS configs (`babel.config.js`, `.babelrc`) and ESM configs (`babel.config.mjs`, a `"type": "module"` project). Input covers ESM (default, named, aliased, and namespace imports) and CJS (`require`, `require(...).default`, destructured require). Babel's modules transform is checked both ways (preset-env `modules: false` and `'commonjs'`). Transformed output runs as native ESM and as CJS. -- Known interop facts: v5 and v6 expose styled as `module.exports.default` and have no `exports` map, so a default import under native Node ESM yields the module object (a packaging limit, not a plugin bug). v7 `require()` returns the callable styled with named exports attached. On `require`, an aliased import, and a namespace import, only intrinsic tags and `styled()` calls receive `.withConfig`. A default or named import whose local name is `styled` still decorates unknown properties. Object methods, known non-tag exports, and a local that shadows or replaces the binding never receive `.withConfig`. A computed variable key is a dynamic lookup, not the identifier's spelling. +- Known interop facts: v5 and v6 expose styled as `module.exports.default` and have no `exports` map, so a default import under native Node ESM yields the module object (a packaging limit, not a plugin bug). v7 `require()` returns the callable styled and that function is a proxy, so an unknown key is a component. A binding that is the factory (require, a default import, a named `styled` import, `.default` / `['default']` of a require or a namespace, and an interop default helper) decorates unknown properties and skips object methods and known non-tag exports. A namespace import uses the same member rules, because a CommonJS transform binds it to the module exports. A local that shadows the binding, or a use that appears after a reassignment, never receives `.withConfig`. A use written before the reassignment keeps its display name and component id. That decision is source order, not reachability. A computed key resolves when it is a string literal, a template with no interpolation, or a const binding of those, and then uses the skip set. A key that does not resolve is decorated, which can throw if the runtime value is a helper. - A change to `isStyled` or the helper detectors is judged only after generating the full shape matrix: every import form (default, named, aliased, namespace, require, interop helper) x access form (dot, `.default`, computed literal, computed variable, call, `.attrs` / `.withConfig` chain, sequence) x export (intrinsic tags, known helpers, object methods, unknown names) x option preset. `node scripts/compat.js` runs a compact version of that matrix against v5, v6, and the latest v7 prerelease. A difference from the last release is a regression unless it is the intended fix. - Output changes (hash shifts, newly recognized forms, minify differences) need a changeset line even when intended. - Perf: run `pnpm bench` on main and the branch for changes that touch visitors or detectors. A slower branch is a regression. diff --git a/scripts/compat.js b/scripts/compat.js index 1588f78d..06882de2 100644 --- a/scripts/compat.js +++ b/scripts/compat.js @@ -203,9 +203,9 @@ export function check() { // Compact import x access x export matrix. Compile-only: the full generator is // the audit harness, and this is the slice `node scripts/compat.js` always runs. -const MATRIX_TAGS = new Set(['div', 'View']) -// Open mode (`import styled`) still decorates unknown names. It skips object -// methods and known non-tag exports, same as OPEN_SKIP in the detector. +// Every factory binding uses open mode: unknown names are decorated, and object +// methods plus known non-tag exports are not. A namespace import uses the same +// member rules because a CommonJS transform binds it to the module exports. const MATRIX_OPEN_SKIP = new Set([ 'isStyledComponent', 'css', @@ -215,35 +215,30 @@ const MATRIX_OPEN_SKIP = new Set([ const MATRIX_IMPORTS = [ { name: 'require sc', - mode: 'allow', sourceType: 'script', bind: 'sc', head: `const sc = require('styled-components')`, }, { name: 'import styled', - mode: 'open', sourceType: 'module', bind: 'styled', head: `import styled from 'styled-components'`, }, { name: 'import sc', - mode: 'allow', sourceType: 'module', bind: 'sc', head: `import sc from 'styled-components'`, }, { name: 'import { styled as s }', - mode: 'allow', sourceType: 'module', bind: 's', head: `import { styled as s } from 'styled-components'`, }, { name: 'import * as ns', - mode: 'namespace', sourceType: 'module', bind: 'ns', head: `import * as ns from 'styled-components'`, @@ -256,16 +251,24 @@ const MATRIX_ACCESS = [ { name: 'default.computed', expr: (b, x) => `${b}.default['${x}']` }, { name: 'computed-default.dot', expr: (b, x) => `${b}['default'].${x}` }, ] -const MATRIX_EXPORTS = ['div', 'View', 'isStyledComponent', 'css', 'hasOwnProperty', 'somethingNew'] -const matrixMemberDecorated = (mode, name) => { - if (mode === 'open') return !MATRIX_OPEN_SKIP.has(name) - return MATRIX_TAGS.has(name) -} +const MATRIX_EXPORTS = [ + 'div', + 'View', + 'isStyledComponent', + 'css', + 'hasOwnProperty', + 'somethingNew', + 'box', + 'foo', + 'feNotAFilter', + 'marquee', +] +const matrixMemberDecorated = name => !MATRIX_OPEN_SKIP.has(name) for (const imp of MATRIX_IMPORTS) { for (const access of MATRIX_ACCESS) { for (const name of MATRIX_EXPORTS) { - const decorated = matrixMemberDecorated(imp.mode, name) + const decorated = matrixMemberDecorated(name) CASES.push({ name: `matrix ${imp.name} ${access.name} ${name}`, sourceType: imp.sourceType, @@ -341,7 +344,7 @@ const Comp = sc.default[css]\`color: red;\` name: 'matrix object method and unknown export do not crash', sourceType: 'script', majors: [5, 6, 7], - plain: ['hasOwnProperty.withConfig', 'toString.withConfig', 'somethingNew.withConfig'], + plain: ['hasOwnProperty.withConfig', 'toString.withConfig'], code: ` const sc = require('styled-components') const Comp = sc.default.div\`color: red;\` @@ -367,6 +370,63 @@ exports.Comp = Comp exports.check = () => { if (!Comp.styledComponentId) throw new Error('computed default call was not decorated') } +`, + }, + { + name: 'matrix computed const helper key is not decorated', + sourceType: 'script', + compileOnly: true, + expectWithConfig: false, + code: ` +const sc = require('styled-components') +const name = 'css' +const Comp = sc[name]\`color: red;\` +const Other = sc[\`css\`]\`color: blue;\` +const Third = sc.default[name]\`color: green;\` +`, + }, + { + name: 'matrix computed const tag and spelled helper are decorated', + sourceType: 'script', + compileOnly: true, + expectWithConfig: true, + present: ['sc[tag].withConfig', 'sc[css].withConfig'], + absent: ['sc[name].withConfig', 'sc[`css`].withConfig'], + code: ` +const sc = require('styled-components') +const tag = 'box' +const css = 'div' +const name = 'css' +const Comp = sc[tag]\`color: red;\` +const ViaDiv = sc[css]\`color: blue;\` +const Skipped = sc[name]\`color: green;\` +const SkippedTpl = sc[\`css\`]\`color: orange;\` +`, + }, + { + name: 'matrix unresolved computed key is decorated', + sourceType: 'script', + compileOnly: true, + expectWithConfig: true, + present: ['sc[dynamic].withConfig'], + code: ` +const sc = require('styled-components') +let dynamic = 'css' +const Comp = sc[dynamic]\`color: red;\` +`, + }, + { + name: 'matrix require use before reassignment stays decorated', + sourceType: 'script', + compileOnly: true, + expectWithConfig: true, + present: ['Before = sc.div.withConfig'], + absent: ['After = sc.div.withConfig'], + code: ` +let sc = require('styled-components') +const Before = sc.div\`color: red;\` +sc = { div: s => () => null } +const After = sc.div\`color: blue;\` `, } ) diff --git a/src/utils/detectors.js b/src/utils/detectors.js index 4163aa18..89850b75 100644 --- a/src/utils/detectors.js +++ b/src/utils/detectors.js @@ -1,4 +1,3 @@ -import { isStyledTag } from './domElements' import getSequenceExpressionValue from './getSequenceExpressionValue' import { useTopLevelImportPathMatchers } from './options' @@ -112,11 +111,10 @@ export const importLocalName = (name, state) => { return localName } -// Members of `import styled` / `import { styled }` that are real functions -// without `.withConfig`. Unknown names stay decorated: on v7 that binding is -// a proxy, so `styled.notAnExport` is a tag factory. Require bindings, -// namespace imports, and every other local name use the tag allowlist instead, -// which does not need this set. +// Real functions on the styled factory that have no `.withConfig`. On v7 the +// factory is a proxy, so any other name is a component and stays decorated. +// The same set applies to require(), a default or named `styled` import, a +// namespace import, and `.default` / `['default']` of those. const OPEN_SKIP = new Set([ 'constructor', 'hasOwnProperty', @@ -162,14 +160,46 @@ const OPEN_SKIP = new Set([ const INTEROP_REQUIRE = new Set(['_interopRequireDefault', '__importDefault']) -// `sc.div` / `sc['div']` have a static name. `sc[name]` is dynamic: the -// identifier is the variable, not the property, so its spelling is not a key. -const memberKey = (t, member) => { +// A computed key whose value is known at compile time: a string, a template +// with no interpolation, or a const binding of one of those. `let` and `var` +// are not folded, and neither is any other expression. Those stay dynamic and +// are decorated, which throws if the runtime value is a helper. +const resolveStaticKey = (t, node, scope, seen) => { + const current = unwrapType(node) + if (!current) return null + if (t.isStringLiteral(current)) return current.value + if ( + t.isTemplateLiteral(current) && + (!current.expressions || current.expressions.length === 0) + ) { + const cooked = current.quasis[0] && current.quasis[0].value.cooked + return cooked == null ? null : cooked + } + if (!t.isIdentifier(current) || !scope) return null + const binding = scope.getBinding(current.name) + if ( + !binding || + binding.kind !== 'const' || + !binding.path.isVariableDeclarator() + ) { + return null + } + if (seen && seen.has(binding)) return null + const next = seen || new Set() + next.add(binding) + return resolveStaticKey(t, binding.path.node.init, binding.scope, next) +} + +// `sc.div` / `sc['div']` / `sc[\`div\`]` / `const tag = 'div'; sc[tag]` have a +// static name. Anything else is a dynamic lookup. The identifier's spelling +// is not a key. +const memberKey = (t, member, scope) => { if (!t.isMemberExpression(member)) return null if (member.computed) { - return t.isStringLiteral(member.property) - ? { kind: 'static', name: member.property.value } - : { kind: 'dynamic' } + const resolved = resolveStaticKey(t, member.property, scope) + return resolved == null + ? { kind: 'dynamic' } + : { kind: 'static', name: resolved } } return t.isIdentifier(member.property) ? { kind: 'static', name: member.property.name } @@ -219,13 +249,25 @@ export const requireBindingName = (t, id, init, state) => { } // `.default` / `['default']`, or null when this member is not that key. -const staticDefaultObject = (t, node) => { +const staticDefaultObject = (t, node, scope) => { if (!t.isMemberExpression(node)) return null - const key = memberKey(t, node) + const key = memberKey(t, node, scope) if (!key || key.kind !== 'static' || key.name !== 'default') return null return node.object } +// A later `sc = ...` in the same scope. Uses that appear before it still see +// the original factory. Source order, not reachability: a use written above +// the assignment stays decorated even when it runs after the assignment. +const assignmentPrecedes = (binding, referenceNode) => { + if (!binding || binding.constant !== false) return false + if (!referenceNode || typeof referenceNode.start !== 'number') return true + return binding.constantViolations.some(violation => { + const start = violation.node && violation.node.start + return typeof start === 'number' && start < referenceNode.start + }) +} + const importedNameOf = (t, specifier) => { const imported = specifier.imported if (t.isIdentifier(imported)) return imported.name @@ -233,16 +275,16 @@ const importedNameOf = (t, specifier) => { return null } -// 'open': default or named `styled` whose local name is `styled`. Any member -// except helpers, object methods, and known non-tag exports. -// 'allow': require() (and its `.default`), or an import with another local -// name. Intrinsic tags and styled() calls only. -// 'namespace': `import * as ns`. Same members as 'allow'. The call `ns(Comp)` -// is decorated too: a CommonJS transform binds `ns` to the module exports, -// which is the styled function. A native ESM namespace is not callable, so -// that call throws with or without `.withConfig`. -// null: this name is not a styled binding in `scope`. -const styledBindingKind = (name, scope, state, t, seen) => { +// 'open': the styled factory. Unknown members are decorated. Object methods +// and known non-tag exports are not. This is require(), a default import, a +// named `styled` import (any local name), `.default` of those, and the +// `_interopRequireDefault` / `__importDefault` helper. +// 'namespace': `import * as ns`. Same members as 'open': a CommonJS transform +// binds `ns` to the module exports, which is the factory on these packages. +// The call `ns(Comp)` is decorated too. A native ESM namespace is not +// callable, so that call throws with or without `.withConfig`. +// null: this name is not a styled binding at `referenceNode`. +const styledBindingKind = (name, scope, state, t, seen, referenceNode) => { if (!name || !scope) return null const token = `${scope.uid}:${name}` if (seen && seen.has(token)) return null @@ -261,8 +303,7 @@ const styledBindingKind = (name, scope, state, t, seen) => { return null } - // A reassigned `let` / `var` is not the original module for its whole scope. - if (binding.constant === false) return null + if (assignmentPrecedes(binding, referenceNode)) return null const bpath = binding.path if ( @@ -279,7 +320,7 @@ const styledBindingKind = (name, scope, state, t, seen) => { const importedName = importedNameOf(t, bpath.node) if (importedName !== 'styled' && importedName !== 'default') return null } - return bpath.node.local.name === 'styled' ? 'open' : 'allow' + return 'open' } if (!bpath.isVariableDeclarator() || !t.isIdentifier(bpath.node.id)) { @@ -291,12 +332,12 @@ const styledBindingKind = (name, scope, state, t, seen) => { // Noted only once assignStyledRequired has walked the declarator, so a // require that sits below the first css prop is not numbered in that // pre-pass. `.default` / `['default']` of the call is the same binding. - const peeled = staticDefaultObject(t, init) + const peeled = staticDefaultObject(t, init, binding.scope) if ( isStyledRequireCall(t, init, state) || (peeled && isStyledRequireCall(t, peeled, state)) ) { - return styledRequireVisited(state, bpath.node.id.name) ? 'allow' : null + return styledRequireVisited(state, bpath.node.id.name) ? 'open' : null } const chain = seen || new Set() @@ -310,15 +351,16 @@ const styledBindingKind = (name, scope, state, t, seen) => { binding.scope, state, t, - chain + chain, + peeled ) - if (baseKind === 'allow' || baseKind === 'namespace') return 'allow' + if (baseKind === 'open' || baseKind === 'namespace') return 'open' } return null } if (t.isIdentifier(init)) { - return styledBindingKind(init.name, binding.scope, state, t, chain) + return styledBindingKind(init.name, binding.scope, state, t, chain, init) } return null @@ -326,17 +368,17 @@ const styledBindingKind = (name, scope, state, t, seen) => { const decorateKeyedMember = (kind, key, property, state, t) => { if (!kind || !key) return false + // Unresolved computed key. Decorated unless the key expression itself is a + // helper identifier (`sc[css]`), which 2.3.0 also left alone. if (key.kind === 'dynamic') return !isHelper(t)(property, state) if (key.kind !== 'static' || key.name === 'default') return false - if (kind === 'open') { - return !OPEN_SKIP.has(key.name) && !isHelper(t)(property, state) - } - return isStyledTag(key.name) + if (kind === 'open' || kind === 'namespace') return !OPEN_SKIP.has(key.name) + return false } -const isDefaultMember = (t, node) => { +const isDefaultMember = (t, node, scope) => { if (!t.isMemberExpression(node) || !t.isIdentifier(node.object)) return false - const key = memberKey(t, node) + const key = memberKey(t, node, scope) return !!key && key.kind === 'static' && key.name === 'default' } @@ -345,7 +387,7 @@ export const isStyled = t => (tag, state, scope) => { scope || (state.file && state.file.path && state.file.path.scope) if (t.isCallExpression(tag) && t.isMemberExpression(tag.callee)) { - const key = memberKey(t, tag.callee) + const key = memberKey(t, tag.callee, lookup) // `.attrs()` / `.withConfig()` chains. `['default']` is the styled // function, same as `.default`, so it is not peeled off here. if (!(key && key.kind === 'static' && key.name === 'default')) { @@ -357,7 +399,7 @@ export const isStyled = t => (tag, state, scope) => { t.isMemberExpression(getSequenceExpressionValue(tag.callee)) ) { const value = getSequenceExpressionValue(tag.callee) - const key = memberKey(t, value) + const key = memberKey(t, value, lookup) if (!(key && key.kind === 'static' && key.name === 'default')) { return isStyled(t)(value, state, lookup) } @@ -368,31 +410,69 @@ export const isStyled = t => (tag, state, scope) => { ? getSequenceExpressionValue(tag.callee) : tag.callee if (t.isIdentifier(callee)) { - const kind = styledBindingKind(callee.name, lookup, state, t) - return kind === 'open' || kind === 'allow' || kind === 'namespace' + const kind = styledBindingKind( + callee.name, + lookup, + state, + t, + null, + callee + ) + return kind === 'open' || kind === 'namespace' } - if (isDefaultMember(t, callee)) { - const kind = styledBindingKind(callee.object.name, lookup, state, t) - return kind === 'open' || kind === 'allow' || kind === 'namespace' + if (isDefaultMember(t, callee, lookup)) { + const kind = styledBindingKind( + callee.object.name, + lookup, + state, + t, + null, + callee.object + ) + return kind === 'open' || kind === 'namespace' } return false } if (!t.isMemberExpression(tag)) return false - if (isDefaultMember(t, tag.object) && t.isIdentifier(tag.object.object)) { - const kind = styledBindingKind(tag.object.object.name, lookup, state, t) + if ( + isDefaultMember(t, tag.object, lookup) && + t.isIdentifier(tag.object.object) + ) { + const kind = styledBindingKind( + tag.object.object.name, + lookup, + state, + t, + null, + tag.object.object + ) if (!kind) return false - const outer = memberKey(t, tag) - // `sc.default[name]` is a runtime lookup. The require path has always - // decorated it, including when the variable is spelled like a helper. + const outer = memberKey(t, tag, lookup) + // `.default` is the factory, so its members use the open skip set. + // An unresolved computed key stays decorated, including when the + // variable is spelled like a helper. if (outer && outer.kind === 'dynamic') return true - return decorateKeyedMember(kind, outer, tag.property, state, t) + return decorateKeyedMember('open', outer, tag.property, state, t) } if (!t.isIdentifier(tag.object)) return false - const kind = styledBindingKind(tag.object.name, lookup, state, t) - return decorateKeyedMember(kind, memberKey(t, tag), tag.property, state, t) + const kind = styledBindingKind( + tag.object.name, + lookup, + state, + t, + null, + tag.object + ) + return decorateKeyedMember( + kind, + memberKey(t, tag, lookup), + tag.property, + state, + t + ) } export const isCSSHelper = t => (tag, state) => diff --git a/src/utils/domElements.js b/src/utils/domElements.js deleted file mode 100644 index e381f5c2..00000000 --- a/src/utils/domElements.js +++ /dev/null @@ -1,200 +0,0 @@ -// Intrinsic tags styled-components attaches to the styled function. Copied -// here so the plugin does not import the runtime. The DOM list is the union -// of the v7 `elements` table and the v5 names that table dropped (`base`, -// `marquee`, and the rest). The React Native aliases are the `styled` -// shortcuts from `styled-components/native`. -const STYLED_TAGS = new Set([ - 'a', - 'abbr', - 'address', - 'area', - 'article', - 'aside', - 'audio', - 'b', - 'base', - 'bdi', - 'bdo', - 'big', - 'blockquote', - 'body', - 'br', - 'button', - 'canvas', - 'caption', - 'cite', - 'code', - 'col', - 'colgroup', - 'data', - 'datalist', - 'dd', - 'del', - 'details', - 'dfn', - 'dialog', - 'div', - 'dl', - 'dt', - 'em', - 'embed', - 'fieldset', - 'figcaption', - 'figure', - 'footer', - 'form', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'head', - 'header', - 'hgroup', - 'hr', - 'html', - 'i', - 'iframe', - 'img', - 'input', - 'ins', - 'kbd', - 'keygen', - 'label', - 'legend', - 'li', - 'link', - 'main', - 'map', - 'mark', - 'marquee', - 'menu', - 'menuitem', - 'meta', - 'meter', - 'nav', - 'noscript', - 'object', - 'ol', - 'optgroup', - 'option', - 'output', - 'p', - 'param', - 'picture', - 'pre', - 'progress', - 'q', - 'rp', - 'rt', - 'ruby', - 's', - 'samp', - 'script', - 'search', - 'section', - 'select', - 'slot', - 'small', - 'source', - 'span', - 'strong', - 'style', - 'sub', - 'summary', - 'sup', - 'table', - 'tbody', - 'td', - 'template', - 'textarea', - 'tfoot', - 'th', - 'thead', - 'time', - 'title', - 'tr', - 'track', - 'u', - 'ul', - 'var', - 'video', - 'wbr', - 'circle', - 'clipPath', - 'defs', - 'ellipse', - 'feBlend', - 'feColorMatrix', - 'feComponentTransfer', - 'feComposite', - 'feConvolveMatrix', - 'feDiffuseLighting', - 'feDisplacementMap', - 'feDistantLight', - 'feDropShadow', - 'feFlood', - 'feFuncA', - 'feFuncB', - 'feFuncG', - 'feFuncR', - 'feGaussianBlur', - 'feImage', - 'feMerge', - 'feMergeNode', - 'feMorphology', - 'feOffset', - 'fePointLight', - 'feSpecularLighting', - 'feSpotLight', - 'feTile', - 'feTurbulence', - 'filter', - 'foreignObject', - 'g', - 'image', - 'line', - 'linearGradient', - 'marker', - 'mask', - 'path', - 'pattern', - 'polygon', - 'polyline', - 'radialGradient', - 'rect', - 'stop', - 'svg', - 'switch', - 'symbol', - 'text', - 'textPath', - 'tspan', - 'use', - 'ActivityIndicator', - 'Button', - 'FlatList', - 'Image', - 'ImageBackground', - 'InputAccessoryView', - 'KeyboardAvoidingView', - 'Modal', - 'Pressable', - 'RefreshControl', - 'SafeAreaView', - 'ScrollView', - 'SectionList', - 'StatusBar', - 'Switch', - 'Text', - 'TextInput', - 'TouchableHighlight', - 'TouchableNativeFeedback', - 'TouchableOpacity', - 'TouchableWithoutFeedback', - 'View', - 'VirtualizedList', -]) - -export const isStyledTag = name => STYLED_TAGS.has(name) diff --git a/test/fixtures/aliased-import-allowlist/code.js b/test/fixtures/aliased-import-allowlist/code.js index a7ffc5b7..826251f2 100644 --- a/test/fixtures/aliased-import-allowlist/code.js +++ b/test/fixtures/aliased-import-allowlist/code.js @@ -6,3 +6,8 @@ const FromNamed = s.View`color: blue;` const helper = sc.isStyledComponent(FromDefault) const unknown = s.somethingNew`color: green;` const owned = sc.hasOwnProperty('div') +const Box = sc.box`color: white;` +const Filter = s.feNotAFilter`color: black;` +const helperCss = sc.css`color: pink;` +const helperDefault = sc.default.css`color: teal;` +const FromDefaultProxy = sc.default.marquee`color: gray;` diff --git a/test/fixtures/aliased-import-allowlist/output.js b/test/fixtures/aliased-import-allowlist/output.js index 5ff95e74..85d1648a 100644 --- a/test/fixtures/aliased-import-allowlist/output.js +++ b/test/fixtures/aliased-import-allowlist/output.js @@ -9,5 +9,22 @@ const FromNamed = s.View.withConfig({ componentId: "sc-ijl2tq-1" })(["color: blue;"]); const helper = sc.isStyledComponent(FromDefault); -const unknown = s.somethingNew`color: green;`; +const unknown = s.somethingNew.withConfig({ + displayName: "code__unknown", + componentId: "sc-ijl2tq-2" +})(["color: green;"]); const owned = sc.hasOwnProperty('div'); +const Box = sc.box.withConfig({ + displayName: "code__Box", + componentId: "sc-ijl2tq-3" +})(["color: white;"]); +const Filter = s.feNotAFilter.withConfig({ + displayName: "code__Filter", + componentId: "sc-ijl2tq-4" +})(["color: black;"]); +const helperCss = sc.css`color: pink;`; +const helperDefault = sc.default.css`color: teal;`; +const FromDefaultProxy = sc.default.marquee.withConfig({ + displayName: "code__FromDefaultProxy", + componentId: "sc-ijl2tq-5" +})(["color: gray;"]); diff --git a/test/fixtures/namespace-default-non-tag-exports/code.js b/test/fixtures/namespace-default-non-tag-exports/code.js index d3e2d96f..50b0d1b5 100644 --- a/test/fixtures/namespace-default-non-tag-exports/code.js +++ b/test/fixtures/namespace-default-non-tag-exports/code.js @@ -7,3 +7,5 @@ const viaComputed = ns['isStyledComponent'](Tagged) const Also = ns.default['div']`color: blue;` const helperCss = ns.default.css`color: green;` const helperComputed = ns['css']`color: purple;` +const Proxy = ns.box`color: white;` +const ProxyDefault = ns.default.foo`color: black;` diff --git a/test/fixtures/namespace-default-non-tag-exports/output.js b/test/fixtures/namespace-default-non-tag-exports/output.js index 78cdfc46..e3783e80 100644 --- a/test/fixtures/namespace-default-non-tag-exports/output.js +++ b/test/fixtures/namespace-default-non-tag-exports/output.js @@ -12,3 +12,11 @@ const Also = ns.default['div'].withConfig({ })(["color: blue;"]); const helperCss = ns.default.css`color: green;`; const helperComputed = ns['css']`color: purple;`; +const Proxy = ns.box.withConfig({ + displayName: "code__Proxy", + componentId: "sc-18vnb1u-2" +})(["color: white;"]); +const ProxyDefault = ns.default.foo.withConfig({ + displayName: "code__ProxyDefault", + componentId: "sc-18vnb1u-3" +})(["color: black;"]); diff --git a/test/fixtures/namespace-direct-call/code.js b/test/fixtures/namespace-direct-call/code.js index 04ebac39..2ae863d1 100644 --- a/test/fixtures/namespace-direct-call/code.js +++ b/test/fixtures/namespace-direct-call/code.js @@ -11,3 +11,5 @@ const helper = ns.isStyledComponent(Tag) const renamed = ns.default const Aliased = renamed.span`color: purple;` const unknown = renamed.somethingNew`color: black;` +const Proxy = ns.box`color: white;` +const skipped = ns.css`color: yellow;` diff --git a/test/fixtures/namespace-direct-call/output.js b/test/fixtures/namespace-direct-call/output.js index 1cff3b6c..57dbac4a 100644 --- a/test/fixtures/namespace-direct-call/output.js +++ b/test/fixtures/namespace-direct-call/output.js @@ -20,4 +20,12 @@ const Aliased = renamed.span.withConfig({ displayName: "code__Aliased", componentId: "sc-p2vwnu-3" })(["color: purple;"]); -const unknown = renamed.somethingNew`color: black;`; +const unknown = renamed.somethingNew.withConfig({ + displayName: "code__unknown", + componentId: "sc-p2vwnu-4" +})(["color: black;"]); +const Proxy = ns.box.withConfig({ + displayName: "code__Proxy", + componentId: "sc-p2vwnu-5" +})(["color: white;"]); +const skipped = ns.css`color: yellow;`; diff --git a/test/fixtures/pre-transpiled-tsc-importdefault/code.js b/test/fixtures/pre-transpiled-tsc-importdefault/code.js index 211d54ca..ddfbde17 100644 --- a/test/fixtures/pre-transpiled-tsc-importdefault/code.js +++ b/test/fixtures/pre-transpiled-tsc-importdefault/code.js @@ -14,3 +14,9 @@ var Foo = styled_components_1.default.div` var Bar = (0, styled_components_1.default)('span')` color: blue; `; +var Box = styled_components_1.default.box` + color: green; +`; +var helper = styled_components_1.default.css` + color: pink; +`; diff --git a/test/fixtures/pre-transpiled-tsc-importdefault/output.js b/test/fixtures/pre-transpiled-tsc-importdefault/output.js index 088d3e95..af1b2eaa 100644 --- a/test/fixtures/pre-transpiled-tsc-importdefault/output.js +++ b/test/fixtures/pre-transpiled-tsc-importdefault/output.js @@ -21,3 +21,10 @@ var Bar = (0, styled_components_1.default)('span').withConfig({ displayName: "Bar", componentId: "sc-1km53of-1" })`color:blue;`; +var Box = styled_components_1.default.box.withConfig({ + displayName: "Box", + componentId: "sc-1km53of-2" +})`color:green;`; +var helper = styled_components_1.default.css` + color: pink; +`; diff --git a/test/fixtures/require-computed-static-key/.babelrc b/test/fixtures/require-computed-static-key/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-computed-static-key/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-computed-static-key/code.js b/test/fixtures/require-computed-static-key/code.js new file mode 100644 index 00000000..99bfc857 --- /dev/null +++ b/test/fixtures/require-computed-static-key/code.js @@ -0,0 +1,17 @@ +const sc = require('styled-components') + +const name = 'css' +const alias = name +const tag = 'box' +const css = 'div' +let dynamic = 'css' + +const Skipped = sc[name]`color: red;` +const SkippedAlias = sc[alias]`color: blue;` +const SkippedTpl = sc[`css`]`color: green;` +const SkippedDefault = sc.default[name]`color: orange;` +const ViaDiv = sc[css]`color: purple;` +const Tag = sc[tag]`color: pink;` +// A key that is not a const string stays decorated, even when its runtime +// value is a helper. `let` and `var` are not folded. +const Dynamic = sc[dynamic]`color: black;` diff --git a/test/fixtures/require-computed-static-key/output.js b/test/fixtures/require-computed-static-key/output.js new file mode 100644 index 00000000..597244b7 --- /dev/null +++ b/test/fixtures/require-computed-static-key/output.js @@ -0,0 +1,24 @@ +const sc = require('styled-components'); +const name = 'css'; +const alias = name; +const tag = 'box'; +const css = 'div'; +let dynamic = 'css'; +const Skipped = sc[name]`color: red;`; +const SkippedAlias = sc[alias]`color: blue;`; +const SkippedTpl = sc[`css`]`color: green;`; +const SkippedDefault = sc.default[name]`color: orange;`; +const ViaDiv = sc[css].withConfig({ + displayName: "code__ViaDiv", + componentId: "sc-1v8m2kx-0" +})(["color: purple;"]); +const Tag = sc[tag].withConfig({ + displayName: "code__Tag", + componentId: "sc-1v8m2kx-1" +})(["color: pink;"]); +// A key that is not a const string stays decorated, even when its runtime +// value is a helper. `let` and `var` are not folded. +const Dynamic = sc[dynamic].withConfig({ + displayName: "code__Dynamic", + componentId: "sc-1v8m2kx-2" +})(["color: black;"]); diff --git a/test/fixtures/require-default-alias/output.js b/test/fixtures/require-default-alias/output.js index b3d1b5c2..08b0cc41 100644 --- a/test/fixtures/require-default-alias/output.js +++ b/test/fixtures/require-default-alias/output.js @@ -5,4 +5,7 @@ const Tag = styled.div.withConfig({ componentId: "sc-oqdthy-0" })(["color: red;"]); const helper = styled.isStyledComponent(Tag); -const unknown = styled.somethingNew`color: blue;`; +const unknown = styled.somethingNew.withConfig({ + displayName: "code__unknown", + componentId: "sc-oqdthy-1" +})(["color: blue;"]); diff --git a/test/fixtures/require-default-binding/output.js b/test/fixtures/require-default-binding/output.js index 78431bbe..a3eea8cb 100644 --- a/test/fixtures/require-default-binding/output.js +++ b/test/fixtures/require-default-binding/output.js @@ -12,4 +12,7 @@ const Called = viaComputed(Base).withConfig({ componentId: "sc-1odpxj7-1" })(["color: blue;"]); const helper = styled.isStyledComponent(Tag); -const unknown = styled.somethingNew`color: green;`; +const unknown = styled.somethingNew.withConfig({ + displayName: "code__unknown", + componentId: "sc-1odpxj7-2" +})(["color: green;"]); diff --git a/test/fixtures/require-non-tag-exports-styled-name/code.js b/test/fixtures/require-non-tag-exports-styled-name/code.js index 1c039669..1a6bd5e2 100644 --- a/test/fixtures/require-non-tag-exports-styled-name/code.js +++ b/test/fixtures/require-non-tag-exports-styled-name/code.js @@ -5,3 +5,14 @@ const ok = styled.isStyledComponent(x) const theme = styled.createTheme({}) const extracted = styled.extractCSS() const Tag = styled.div`color: red;` +const Box = styled.box`color: blue;` +const Foo = styled.foo`color: green;` +const Filter = styled.feNotAFilter`color: orange;` +const Marquee = styled.marquee`color: purple;` +const helperCss = styled.css`color: pink;` +const helperKeyframes = styled.keyframes`from { opacity: 0; } to { opacity: 1; }` +const helperGlobal = styled.createGlobalStyle`body { margin: 0; }` +const helperComputed = styled['createGlobalStyle']`body { margin: 0; }` +const owned = styled.hasOwnProperty('div') +const text = String(styled.toString()) +const helperDefaultCss = styled.default.css`color: black;` diff --git a/test/fixtures/require-non-tag-exports-styled-name/output.js b/test/fixtures/require-non-tag-exports-styled-name/output.js index 8518e6f3..1db64ea6 100644 --- a/test/fixtures/require-non-tag-exports-styled-name/output.js +++ b/test/fixtures/require-non-tag-exports-styled-name/output.js @@ -7,3 +7,26 @@ const Tag = styled.div.withConfig({ displayName: "code__Tag", componentId: "sc-w432we-0" })(["color: red;"]); +const Box = styled.box.withConfig({ + displayName: "code__Box", + componentId: "sc-w432we-1" +})(["color: blue;"]); +const Foo = styled.foo.withConfig({ + displayName: "code__Foo", + componentId: "sc-w432we-2" +})(["color: green;"]); +const Filter = styled.feNotAFilter.withConfig({ + displayName: "code__Filter", + componentId: "sc-w432we-3" +})(["color: orange;"]); +const Marquee = styled.marquee.withConfig({ + displayName: "code__Marquee", + componentId: "sc-w432we-4" +})(["color: purple;"]); +const helperCss = styled.css`color: pink;`; +const helperKeyframes = styled.keyframes`from { opacity: 0; } to { opacity: 1; }`; +const helperGlobal = styled.createGlobalStyle`body { margin: 0; }`; +const helperComputed = styled['createGlobalStyle']`body { margin: 0; }`; +const owned = styled.hasOwnProperty('div'); +const text = String(styled.toString()); +const helperDefaultCss = styled.default.css`color: black;`; diff --git a/test/fixtures/require-non-tag-exports/code.js b/test/fixtures/require-non-tag-exports/code.js index 90175add..faa0ecfb 100644 --- a/test/fixtures/require-non-tag-exports/code.js +++ b/test/fixtures/require-non-tag-exports/code.js @@ -15,3 +15,7 @@ const D = sc.default['div']`color: orange;` const helperCss = sc['css']`color: purple;` const helperDefaultCss = sc.default.css`color: pink;` const helperKeyframes = sc['keyframes']`from { opacity: 0; } to { opacity: 1; }` +const Box = sc.box`color: white;` +const Foo = sc.default.foo`color: black;` +const Filter = sc['feNotAFilter']`color: teal;` +const Marquee = sc.default['marquee']`color: gray;` diff --git a/test/fixtures/require-non-tag-exports/output.js b/test/fixtures/require-non-tag-exports/output.js index e22ee300..3f4da1a8 100644 --- a/test/fixtures/require-non-tag-exports/output.js +++ b/test/fixtures/require-non-tag-exports/output.js @@ -26,3 +26,19 @@ const D = sc.default['div'].withConfig({ const helperCss = sc['css']`color: purple;`; const helperDefaultCss = sc.default.css`color: pink;`; const helperKeyframes = sc['keyframes']`from { opacity: 0; } to { opacity: 1; }`; +const Box = sc.box.withConfig({ + displayName: "code__Box", + componentId: "sc-c84c7x-4" +})(["color: white;"]); +const Foo = sc.default.foo.withConfig({ + displayName: "code__Foo", + componentId: "sc-c84c7x-5" +})(["color: black;"]); +const Filter = sc['feNotAFilter'].withConfig({ + displayName: "code__Filter", + componentId: "sc-c84c7x-6" +})(["color: teal;"]); +const Marquee = sc.default['marquee'].withConfig({ + displayName: "code__Marquee", + componentId: "sc-c84c7x-7" +})(["color: gray;"]); diff --git a/test/fixtures/require-reassign-before/.babelrc b/test/fixtures/require-reassign-before/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-reassign-before/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-reassign-before/code.js b/test/fixtures/require-reassign-before/code.js new file mode 100644 index 00000000..6f5b2a74 --- /dev/null +++ b/test/fixtures/require-reassign-before/code.js @@ -0,0 +1,14 @@ +let sc = require('styled-components') + +const Before = sc.div`color: red;` + +// Written above the assignment, so this stays decorated even though the +// function runs after `sc` has been replaced. +function writtenAbove() { + return sc.span`color: blue;` +} + +sc = { div: s => () => null, span: s => () => null } + +const After = sc.div`color: green;` +const Late = writtenAbove() diff --git a/test/fixtures/require-reassign-before/output.js b/test/fixtures/require-reassign-before/output.js new file mode 100644 index 00000000..7b337ec6 --- /dev/null +++ b/test/fixtures/require-reassign-before/output.js @@ -0,0 +1,20 @@ +let sc = require('styled-components'); +const Before = sc.div.withConfig({ + displayName: "code__Before", + componentId: "sc-m6xnle-0" +})(["color: red;"]); + +// Written above the assignment, so this stays decorated even though the +// function runs after `sc` has been replaced. +function writtenAbove() { + return sc.span.withConfig({ + displayName: "code", + componentId: "sc-m6xnle-1" + })(["color: blue;"]); +} +sc = { + div: s => () => null, + span: s => () => null +}; +const After = sc.div`color: green;`; +const Late = writtenAbove(); diff --git a/test/fixtures/require-unknown-export/output.js b/test/fixtures/require-unknown-export/output.js index c27061c1..6903c39d 100644 --- a/test/fixtures/require-unknown-export/output.js +++ b/test/fixtures/require-unknown-export/output.js @@ -6,5 +6,11 @@ const Comp = sc.default.div.withConfig({ displayName: "code__Comp", componentId: "sc-9r4q5l-0" })(["color: red;"]); -const n = typeof sc.somethingNew === 'function' ? sc.somethingNew(Base) : null; -const Ghost = sc.somethingNew`color: blue;`; +const n = typeof sc.somethingNew === 'function' ? sc.somethingNew.withConfig({ + displayName: "code__n", + componentId: "sc-9r4q5l-1" +})(Base) : null; +const Ghost = sc.somethingNew.withConfig({ + displayName: "code__Ghost", + componentId: "sc-9r4q5l-2" +})(["color: blue;"]); diff --git a/test/fixtures/string-named-styled-import/output.js b/test/fixtures/string-named-styled-import/output.js index 40ee4031..c15ea720 100644 --- a/test/fixtures/string-named-styled-import/output.js +++ b/test/fixtures/string-named-styled-import/output.js @@ -4,4 +4,7 @@ const Tag = s.div.withConfig({ componentId: "sc-2s7ou2-0" })(["color: red;"]); const helper = s.isStyledComponent(Tag); -const unknown = s.somethingNew`color: blue;`; +const unknown = s.somethingNew.withConfig({ + displayName: "code__unknown", + componentId: "sc-2s7ou2-1" +})(["color: blue;"]); From 5aa81ee3bde8c12e2965999d6c1406fb799a5062 Mon Sep 17 00:00:00 2001 From: Evan Jacobs Date: Thu, 22 Oct 2026 22:14:56 +0000 Subject: [PATCH 7/7] fix(detectors): leave unsafe factory lookups undecorated Skip keys that live on Object.prototype or Function.prototype. None of those names are DOM or SVG tags. A computed key stays plain unless it folds to a known component, except where an import named styled already received an id. A nested function or a loop that can run after a reassignment stays plain, and a straight-line use before that assignment keeps its id. Co-authored-by: Evan Jacobs --- .changeset/require-non-tag-exports.md | 2 +- AGENTS.md | 2 +- scripts/compat.js | 183 +++++++++++++- src/utils/detectors.js | 231 +++++++++++++++--- .../import-alias-unresolved-key/.babelrc | 14 ++ .../import-alias-unresolved-key/code.js | 8 + .../import-alias-unresolved-key/output.js | 9 + .../import-default-as-unresolved-key/.babelrc | 14 ++ .../import-default-as-unresolved-key/code.js | 5 + .../output.js | 6 + .../.babelrc | 14 ++ .../code.js | 7 + .../output.js | 8 + .../import-styled-unresolved-key/.babelrc | 14 ++ .../import-styled-unresolved-key/code.js | 5 + .../import-styled-unresolved-key/output.js | 6 + .../namespace-unresolved-key/.babelrc | 14 ++ .../fixtures/namespace-unresolved-key/code.js | 5 + .../namespace-unresolved-key/output.js | 6 + .../require-computed-static-key/code.js | 8 +- .../require-computed-static-key/output.js | 18 +- test/fixtures/require-object-methods/code.js | 10 + .../fixtures/require-object-methods/output.js | 10 + test/fixtures/require-reassign-before/code.js | 3 +- .../require-reassign-before/output.js | 8 +- test/fixtures/require-reassign-loop/.babelrc | 15 ++ test/fixtures/require-reassign-loop/code.js | 32 +++ test/fixtures/require-reassign-loop/output.js | 40 +++ .../.babelrc | 15 ++ .../code.js | 5 + .../output.js | 6 + 31 files changed, 671 insertions(+), 52 deletions(-) create mode 100644 test/fixtures/import-alias-unresolved-key/.babelrc create mode 100644 test/fixtures/import-alias-unresolved-key/code.js create mode 100644 test/fixtures/import-alias-unresolved-key/output.js create mode 100644 test/fixtures/import-default-as-unresolved-key/.babelrc create mode 100644 test/fixtures/import-default-as-unresolved-key/code.js create mode 100644 test/fixtures/import-default-as-unresolved-key/output.js create mode 100644 test/fixtures/import-styled-alias-unresolved-key/.babelrc create mode 100644 test/fixtures/import-styled-alias-unresolved-key/code.js create mode 100644 test/fixtures/import-styled-alias-unresolved-key/output.js create mode 100644 test/fixtures/import-styled-unresolved-key/.babelrc create mode 100644 test/fixtures/import-styled-unresolved-key/code.js create mode 100644 test/fixtures/import-styled-unresolved-key/output.js create mode 100644 test/fixtures/namespace-unresolved-key/.babelrc create mode 100644 test/fixtures/namespace-unresolved-key/code.js create mode 100644 test/fixtures/namespace-unresolved-key/output.js create mode 100644 test/fixtures/require-reassign-loop/.babelrc create mode 100644 test/fixtures/require-reassign-loop/code.js create mode 100644 test/fixtures/require-reassign-loop/output.js create mode 100644 test/fixtures/require-styled-name-unresolved-key/.babelrc create mode 100644 test/fixtures/require-styled-name-unresolved-key/code.js create mode 100644 test/fixtures/require-styled-name-unresolved-key/output.js diff --git a/.changeset/require-non-tag-exports.md b/.changeset/require-non-tag-exports.md index 9fbcf6fe..00d3e95b 100644 --- a/.changeset/require-non-tag-exports.md +++ b/.changeset/require-non-tag-exports.md @@ -2,4 +2,4 @@ 'babel-plugin-styled-components': patch --- -A binding that is the styled factory keeps display names and component ids for properties this plugin does not recognize. On styled-components v7 that factory is a proxy, so `styled.box`, `styled.foo`, and `styled.feNotAFilter` are real components. `const styled = require('styled-components')`, `import sc from 'styled-components'`, `sc.default`, `ns.default`, and `__importDefault(require('styled-components')).default` are that factory. Known non-tag exports and object methods stay plain, including `sc.css`, `sc.keyframes`, `sc.createGlobalStyle`, `sc['createGlobalStyle']`, `sc.isStyledComponent`, `sc.hasOwnProperty`, `sc.toString`, and `sc.default.css`. A computed key is resolved when it is a string, a template with no interpolation, or a const initialized to one of those, so `const name = 'css'; sc[name]` and `` sc[`css`] `` stay plain. A key the plugin cannot resolve is still decorated, and that throws if the runtime value is a helper. A `let` or `var` that starts as the factory and is reassigned later keeps its display name on uses written before the reassignment. Uses written after it are left alone. +A binding that is the styled factory keeps display names and component ids for properties this plugin does not recognize. On styled-components v7 that factory is a proxy, so `styled.box`, `styled.foo`, and `styled.feNotAFilter` are real components. `const styled = require('styled-components')`, `import sc from 'styled-components'`, `sc.default`, `ns.default`, and `__importDefault(require('styled-components')).default` are that factory. Known non-tag exports stay plain, including `sc.css`, `sc.keyframes`, `sc.createGlobalStyle`, `sc['createGlobalStyle']`, `sc.isStyledComponent`, and `sc.default.css`. Names on `Object.prototype` or `Function.prototype` stay plain too, including `sc.hasOwnProperty`, `sc.toString`, `sc.bind`, `sc.__lookupGetter__`, `sc.name`, and `sc.__proto__`. A computed key is resolved when it is a string, a template whose parts all fold to strings, or a const initialized to one of those, so `const name = 'css'; sc[name]`, `` sc[`css`] ``, and `` sc[`${'css'}`] `` stay plain, while `const css = 'div'; sc[css]` and `sc.default[css]` keep their ids. A key this plugin cannot resolve stays plain on `require()` and on an alias that is not named `styled`, so `let dynamic = 'css'; sc[dynamic]` returns the helper instead of throwing. `styled[dynamic]` on an import named `styled` is still decorated. A `let` or `var` that starts as the factory keeps its display name on a straight-line use written before the reassignment. A use inside a nested function, or inside a loop that also reassigns the binding, is left alone, so that function and the next loop iteration do not throw. diff --git a/AGENTS.md b/AGENTS.md index 3aa41a55..32319c8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ Review - Every behavior change is checked against styled-components v5 (latest 5.x), v6 (latest 6.x), and v7 (latest prerelease dist-tag). `node scripts/compat.js` compiles smoke programs with the branch and with main, then executes the output (`renderToString` plus `ServerStyleSheet`) against each version. Generated CSS, displayName, and componentId match main except the intended change. No runtime errors. - Module formats: the plugin loads from CJS configs (`babel.config.js`, `.babelrc`) and ESM configs (`babel.config.mjs`, a `"type": "module"` project). Input covers ESM (default, named, aliased, and namespace imports) and CJS (`require`, `require(...).default`, destructured require). Babel's modules transform is checked both ways (preset-env `modules: false` and `'commonjs'`). Transformed output runs as native ESM and as CJS. -- Known interop facts: v5 and v6 expose styled as `module.exports.default` and have no `exports` map, so a default import under native Node ESM yields the module object (a packaging limit, not a plugin bug). v7 `require()` returns the callable styled and that function is a proxy, so an unknown key is a component. A binding that is the factory (require, a default import, a named `styled` import, `.default` / `['default']` of a require or a namespace, and an interop default helper) decorates unknown properties and skips object methods and known non-tag exports. A namespace import uses the same member rules, because a CommonJS transform binds it to the module exports. A local that shadows the binding, or a use that appears after a reassignment, never receives `.withConfig`. A use written before the reassignment keeps its display name and component id. That decision is source order, not reachability. A computed key resolves when it is a string literal, a template with no interpolation, or a const binding of those, and then uses the skip set. A key that does not resolve is decorated, which can throw if the runtime value is a helper. +- Known interop facts: v5 and v6 expose styled as `module.exports.default` and have no `exports` map, so a default import under native Node ESM yields the module object (a packaging limit, not a plugin bug). v7 `require()` returns the callable styled and that function is a proxy, so an unknown key is a component. A binding that is the factory (require, a default import, a named `styled` import, `.default` / `['default']` of a require or a namespace, and an interop default helper) decorates unknown properties and skips known non-tag exports and any key for which `name in Object.prototype || name in Function.prototype` (no DOM or SVG tag is on those prototypes). A namespace import uses the same member rules, because a CommonJS transform binds it to the module exports. A local that shadows the binding, or a use that appears after a reassignment, never receives `.withConfig`. A straight-line use in the binding's own scope that is written before the reassignment keeps its display name and component id. A use inside a nested function, or inside a loop that also reassigns the binding, stays plain, because that code can run after the assignment. A computed key resolves when it is a string literal, a template whose parts all fold to strings, or a const binding of those, and then uses the skip set. A key that does not resolve stays plain on a require or on an alias whose name is not the styled import's local name. `styled[dynamic]` and a default import's own local name stay decorated, matching the previous plugin, and that can throw if the runtime value is a helper. - A change to `isStyled` or the helper detectors is judged only after generating the full shape matrix: every import form (default, named, aliased, namespace, require, interop helper) x access form (dot, `.default`, computed literal, computed variable, call, `.attrs` / `.withConfig` chain, sequence) x export (intrinsic tags, known helpers, object methods, unknown names) x option preset. `node scripts/compat.js` runs a compact version of that matrix against v5, v6, and the latest v7 prerelease. A difference from the last release is a regression unless it is the intended fix. - Output changes (hash shifts, newly recognized forms, minify differences) need a changeset line even when intended. - Perf: run `pnpm bench` on main and the branch for changes that touch visitors or detectors. A slower branch is a regression. diff --git a/scripts/compat.js b/scripts/compat.js index 06882de2..80dd900e 100644 --- a/scripts/compat.js +++ b/scripts/compat.js @@ -404,15 +404,82 @@ const SkippedTpl = sc[\`css\`]\`color: orange;\` `, }, { - name: 'matrix unresolved computed key is decorated', + name: 'matrix unresolved computed key on require is not decorated', sourceType: 'script', compileOnly: true, - expectWithConfig: true, - present: ['sc[dynamic].withConfig'], + expectWithConfig: false, code: ` const sc = require('styled-components') let dynamic = 'css' +const bag = { key: 'css' } +const { key: fromBag } = bag const Comp = sc[dynamic]\`color: red;\` +const FromBag = sc[fromBag]\`color: blue;\` +const Folded = sc[\`\${'css'}\`]\`color: green;\` +`, + }, + { + name: 'matrix unresolved computed key on styled import is decorated', + sourceType: 'module', + compileOnly: true, + expectWithConfig: true, + present: ['styled[dynamic].withConfig', 's2[dynamic].withConfig'], + absent: ['styled[css].withConfig'], + code: ` +import styled, { css } from 'styled-components' +const s2 = styled +let dynamic = 'div' +const Comp = styled[dynamic]\`color: red;\` +const Alias = s2[dynamic]\`color: blue;\` +const Helper = styled[css]\`color: green;\` +`, + }, + { + name: 'matrix unresolved computed key on styled alias is not decorated', + sourceType: 'module', + compileOnly: true, + expectWithConfig: true, + present: ['s.box.withConfig'], + absent: ['s[dynamic].withConfig', 's[themeKey].withConfig'], + code: ` +import { styled as s, ThemeProvider as themeKey } from 'styled-components' +let dynamic = 'div' +const Comp = s.box\`color: red;\` +const Skipped = s[dynamic]\`color: blue;\` +const SkippedImport = s[themeKey]\`color: green;\` +`, + }, + { + name: 'matrix prototype members are not decorated', + sourceType: 'script', + compileOnly: true, + expectWithConfig: true, + present: ['sc.div.withConfig'], + absent: [ + '__lookupGetter__.withConfig', + '__lookupSetter__.withConfig', + '__proto__.withConfig', + '.bind.withConfig', + '.apply.withConfig', + '.call.withConfig', + '.length.withConfig', + '.name.withConfig', + '.arguments.withConfig', + '.caller.withConfig', + ], + code: ` +const sc = require('styled-components') +const Comp = sc.div\`color: red;\` +const Lookup = sc.__lookupGetter__\`x\` +const LookupSet = sc.__lookupSetter__\`x\` +const Proto = sc.__proto__\`x\` +const Bound = sc.bind\`x\` +const Applied = sc.apply\`x\` +const Called = sc.call\`x\` +const Len = sc.length\`x\` +const Named = sc.name\`x\` +const Args = sc.arguments\`x\` +const Caller = sc.caller\`x\` `, }, { @@ -427,6 +494,116 @@ let sc = require('styled-components') const Before = sc.div\`color: red;\` sc = { div: s => () => null } const After = sc.div\`color: blue;\` +`, + }, + { + name: 'matrix require use in nested function is not decorated', + sourceType: 'script', + compileOnly: true, + expectWithConfig: true, + present: ['Before = sc.div.withConfig'], + absent: ['sc.span.withConfig', 'After = sc.div.withConfig'], + code: ` +let sc = require('styled-components') +const Before = sc.div\`color: red;\` +function writtenAbove() { return sc.span\`color: blue;\` } +sc = { span: s => () => null, div: s => () => null } +const Late = writtenAbove() +const After = sc.div\`color: green;\` +`, + }, + { + name: 'matrix require use inside reassignment loop is not decorated', + sourceType: 'script', + compileOnly: true, + expectWithConfig: true, + present: ['First = sc.div.withConfig'], + absent: ['Comp = sc.div.withConfig', 'After = sc.div.withConfig'], + code: ` +let sc = require('styled-components') +const First = sc.div\`color: red;\` +for (let i = 0; i < 2; i++) { + const Comp = sc.div\`color: blue;\` + sc = { div: s => () => null } +} +const After = sc.div\`color: green;\` +`, + }, + { + name: 'closure over reassigned require does not throw', + sourceType: 'script', + majors: [5, 6, 7], + plain: ['sc.span.withConfig'], + present: ['sc.default.div.withConfig'], + code: ` +let sc = require('styled-components') +const real = sc +const Comp = sc.default.div\`color: red;\` +function writtenAbove() { return sc.span\`color: blue;\` } +exports.Comp = Comp +exports.check = () => { + if (typeof real.span !== 'function') return + sc = { span: s => () => null, div: s => () => null } + const Late = writtenAbove() + if (typeof Late !== 'function') throw new Error('closure did not return the stand-in') +} +`, + }, + { + name: 'loop that reassigns require does not throw', + sourceType: 'script', + majors: [5, 6, 7], + plain: ['sc.div.withConfig'], + present: ['sc.default.div.withConfig'], + code: ` +let sc = require('styled-components') +const Comp = sc.default.div\`color: red;\` +exports.Comp = Comp +exports.check = () => { + if (typeof sc.div !== 'function') return + for (let i = 0; i < 2; i++) { + sc.div\`color: blue;\` + sc = { div: s => () => null } + } +} +`, + }, + { + name: 'prototype members do not throw', + sourceType: 'script', + majors: [5, 6, 7], + plain: ['__lookupGetter__.withConfig', '.bind.withConfig'], + present: ['sc.default.div.withConfig'], + code: ` +const sc = require('styled-components') +const Comp = sc.default.div\`color: red;\` +exports.Comp = Comp +exports.check = () => { + if (typeof sc.__lookupGetter__ !== 'function' || typeof sc.bind !== 'function') return + sc.__lookupGetter__\`x\` + const bound = sc.bind\`x\` + if (typeof bound !== 'function') throw new Error('bind was decorated') +} +`, + }, + { + name: 'unresolved require computed key does not throw', + sourceType: 'script', + majors: [5, 6, 7], + plain: ['sc[dynamic].withConfig'], + present: ['sc.default.div.withConfig', 'sc.default[css].withConfig'], + code: ` +const sc = require('styled-components') +const css = 'div' +let dynamic = 'css' +const Comp = sc.default.div\`color: red;\` +const Kept = sc.default[css]\`color: blue;\` +exports.Comp = Comp +exports.check = () => { + const helper = sc[dynamic]\`color: green;\` + if (helper == null) throw new Error('unresolved key did not return the helper') + if (!Kept.styledComponentId) throw new Error('folded default key lost its id') +} `, } ) diff --git a/src/utils/detectors.js b/src/utils/detectors.js index 89850b75..b5e8ba13 100644 --- a/src/utils/detectors.js +++ b/src/utils/detectors.js @@ -43,9 +43,12 @@ const styledRequireVisited = (state, name) => { export const injectedDefaultName = state => state.file.get(INJECTED_DEFAULT_NAME) +const LEGACY_DEFAULT_LOCAL = 'styled-components-legacy-default-local' + export const registerInjectedDefault = (state, name) => { state.file.set(INJECTED_DEFAULT_NAME, name) state.file.set(IMPORT_NAMES_CACHE, null) + state.file.set(LEGACY_DEFAULT_LOCAL, null) } export const importLocalName = (name, state) => { @@ -111,18 +114,10 @@ export const importLocalName = (name, state) => { return localName } -// Real functions on the styled factory that have no `.withConfig`. On v7 the -// factory is a proxy, so any other name is a component and stays decorated. -// The same set applies to require(), a default or named `styled` import, a -// namespace import, and `.default` / `['default']` of those. +// Known non-tag exports. Object and function prototype names are not listed +// here; `isBuiltinPrototypeKey` covers them. None of those names are DOM or +// SVG tags (`name`, `length`, `caller`, and `arguments` included). const OPEN_SKIP = new Set([ - 'constructor', - 'hasOwnProperty', - 'isPrototypeOf', - 'propertyIsEnumerable', - 'toLocaleString', - 'toString', - 'valueOf', 'NativeStyleContext', 'ParentContext', 'SafeAreaInsetsProvider', @@ -161,19 +156,26 @@ const OPEN_SKIP = new Set([ const INTEROP_REQUIRE = new Set(['_interopRequireDefault', '__importDefault']) // A computed key whose value is known at compile time: a string, a template -// with no interpolation, or a const binding of one of those. `let` and `var` -// are not folded, and neither is any other expression. Those stay dynamic and -// are decorated, which throws if the runtime value is a helper. +// whose parts all fold to strings, or a const binding of one of those. +// `let`, `var`, destructuring, and imports do not fold. const resolveStaticKey = (t, node, scope, seen) => { const current = unwrapType(node) if (!current) return null if (t.isStringLiteral(current)) return current.value - if ( - t.isTemplateLiteral(current) && - (!current.expressions || current.expressions.length === 0) - ) { - const cooked = current.quasis[0] && current.quasis[0].value.cooked - return cooked == null ? null : cooked + if (t.isTemplateLiteral(current)) { + const quasis = current.quasis || [] + const expressions = current.expressions || [] + let out = '' + for (let i = 0; i < quasis.length; i++) { + const cooked = quasis[i] && quasis[i].value.cooked + if (cooked == null) return null + out += cooked + if (i >= expressions.length) continue + const part = resolveStaticKey(t, expressions[i], scope, seen) + if (part == null) return null + out += part + } + return out } if (!t.isIdentifier(current) || !scope) return null const binding = scope.getBinding(current.name) @@ -187,7 +189,14 @@ const resolveStaticKey = (t, node, scope, seen) => { if (seen && seen.has(binding)) return null const next = seen || new Set() next.add(binding) - return resolveStaticKey(t, binding.path.node.init, binding.scope, next) + const resolved = resolveStaticKey( + t, + binding.path.node.init, + binding.scope, + next + ) + next.delete(binding) + return resolved } // `sc.div` / `sc['div']` / `sc[\`div\`]` / `const tag = 'div'; sc[tag]` have a @@ -256,11 +265,53 @@ const staticDefaultObject = (t, node, scope) => { return node.object } -// A later `sc = ...` in the same scope. Uses that appear before it still see -// the original factory. Source order, not reachability: a use written above -// the assignment stays decorated even when it runs after the assignment. +const referencePathFor = (binding, referenceNode) => { + const paths = binding.referencePaths + if (!paths || !referenceNode) return null + for (let i = 0; i < paths.length; i++) { + if (paths[i].node === referenceNode) return paths[i] + } + return null +} + +const isLoopPath = path => + path.isForStatement() || + path.isWhileStatement() || + path.isDoWhileStatement() || + path.isForInStatement() || + path.isForOfStatement() + +// A function nested inside the binding's scope can run after a reassignment +// even when the function is written above it. +const inNestedFunction = (binding, refPath) => { + let scope = refPath.scope + while (scope && scope !== binding.scope) { + if (scope.path && scope.path.isFunction()) return true + scope = scope.parent + } + return false +} + +// A loop that both reads the binding and assigns it runs the read again +// after the assignment. A read in a loop whose assignment is outside the +// loop still sees the factory on every iteration. +const loopReassigns = (binding, refPath) => { + const stop = binding.scope && binding.scope.path + const violations = binding.constantViolations || [] + let path = refPath.parentPath + while (path && path !== stop) { + if (isLoopPath(path)) { + for (let i = 0; i < violations.length; i++) { + const violation = violations[i] + if (violation === path || path.isAncestor(violation)) return true + } + } + path = path.parentPath + } + return false +} + const assignmentPrecedes = (binding, referenceNode) => { - if (!binding || binding.constant !== false) return false if (!referenceNode || typeof referenceNode.start !== 'number') return true return binding.constantViolations.some(violation => { const start = violation.node && violation.node.start @@ -268,6 +319,18 @@ const assignmentPrecedes = (binding, referenceNode) => { }) } +// True when this reference must not be treated as the factory. Straight-line +// uses in the binding's scope that sit before the assignment stay decorated. +const reassignmentUnsafe = (binding, referenceNode) => { + if (!binding || binding.constant !== false) return false + const refPath = referencePathFor(binding, referenceNode) + if (refPath) { + if (inNestedFunction(binding, refPath)) return true + if (loopReassigns(binding, refPath)) return true + } + return assignmentPrecedes(binding, referenceNode) +} + const importedNameOf = (t, specifier) => { const imported = specifier.imported if (t.isIdentifier(imported)) return imported.name @@ -303,7 +366,7 @@ const styledBindingKind = (name, scope, state, t, seen, referenceNode) => { return null } - if (assignmentPrecedes(binding, referenceNode)) return null + if (reassignmentUnsafe(binding, referenceNode)) return null const bpath = binding.path if ( @@ -366,12 +429,108 @@ const styledBindingKind = (name, scope, state, t, seen, referenceNode) => { return null } -const decorateKeyedMember = (kind, key, property, state, t) => { +// `in` is safe here: these prototypes are ordinary objects, and the check +// does not read the property. A name that is also a real tag would be kept, +// but no DOM or SVG tag is on either prototype. +const isBuiltinPrototypeKey = name => + name in Object.prototype || name in Function.prototype + +// 2.3.0's default local: the string `'styled'` whenever a require() of the +// package exists, then overwritten by top-level imports. A named `styled` +// import sets the string `'styled'`, not the local alias. A default import +// sets that import's local name. A namespace sets its local name only when +// nothing else did. +const legacyDefaultLocal = (state, t) => { + if (state.customImportName) return state.customImportName.name + + const hadRequired = !!state.styledRequired + const cached = state.file.get(LEGACY_DEFAULT_LOCAL) + if (cached && cached.hadRequired === hadRequired) return cached.value + + let localName = hadRequired ? 'styled' : null + const body = + state.file.path && state.file.path.node && state.file.path.node.body + if (body) { + for (const node of body) { + if (node.type !== 'ImportDeclaration') continue + if (isTypeOnly(node)) continue + if (!isValidTopLevelImport(node.source.value, state)) continue + for (const specifier of node.specifiers) { + if (isTypeOnly(specifier)) continue + const imported = specifier.imported + const importedName = !imported + ? null + : t.isIdentifier(imported) + ? imported.name + : t.isStringLiteral(imported) + ? imported.value + : null + if (specifier.type === 'ImportSpecifier' && importedName === 'styled') { + localName = 'styled' + } + if (specifier.type === 'ImportDefaultSpecifier') { + localName = specifier.local.name + } + if ( + specifier.type === 'ImportSpecifier' && + importedName === 'default' + ) { + localName = specifier.local.name + } + if (specifier.type === 'ImportNamespaceSpecifier' && !localName) { + localName = specifier.local.name + } + } + } + } + + state.file.set(LEGACY_DEFAULT_LOCAL, { hadRequired, value: localName }) + return localName +} + +const resolvesToDefaultLocal = (name, defaultLocal, state, t) => { + if (!name || !defaultLocal) return false + if (name === defaultLocal) return true + const scope = state.file && state.file.path && state.file.path.scope + if (!scope) return false + const visited = new Set([name]) + let current = name + while (true) { + const binding = scope.getBinding(current) + if (!binding || !binding.path.isVariableDeclarator()) return false + const init = binding.path.node.init + if (!init) return false + let nextName = null + if (t.isIdentifier(init)) { + nextName = init.name + } else if ( + (init.type === 'TSAsExpression' || init.type === 'TSTypeAssertion') && + t.isIdentifier(init.expression) + ) { + nextName = init.expression.name + } + if (!nextName) return false + if (nextName === defaultLocal) return true + if (visited.has(nextName)) return false + visited.add(nextName) + current = nextName + } +} + +const legacyDecoratesUnresolved = (objectName, state, t) => + resolvesToDefaultLocal(objectName, legacyDefaultLocal(state, t), state, t) + +const decorateKeyedMember = (kind, key, property, state, t, objectName) => { if (!kind || !key) return false - // Unresolved computed key. Decorated unless the key expression itself is a - // helper identifier (`sc[css]`), which 2.3.0 also left alone. - if (key.kind === 'dynamic') return !isHelper(t)(property, state) + // An unresolved key is decorated only for the names 2.3.0 decorated + // (`styled[dynamic]`, a default import's local name). A require named + // something else, and an alias that is not that name, stay plain. + if (key.kind === 'dynamic') { + if (!legacyDecoratesUnresolved(objectName, state, t)) return false + return !isHelper(t)(property, state) + } if (key.kind !== 'static' || key.name === 'default') return false + if (isBuiltinPrototypeKey(key.name)) return false if (kind === 'open' || kind === 'namespace') return !OPEN_SKIP.has(key.name) return false } @@ -454,7 +613,14 @@ export const isStyled = t => (tag, state, scope) => { // An unresolved computed key stays decorated, including when the // variable is spelled like a helper. if (outer && outer.kind === 'dynamic') return true - return decorateKeyedMember('open', outer, tag.property, state, t) + return decorateKeyedMember( + 'open', + outer, + tag.property, + state, + t, + tag.object.object.name + ) } if (!t.isIdentifier(tag.object)) return false @@ -471,7 +637,8 @@ export const isStyled = t => (tag, state, scope) => { memberKey(t, tag, lookup), tag.property, state, - t + t, + tag.object.name ) } diff --git a/test/fixtures/import-alias-unresolved-key/.babelrc b/test/fixtures/import-alias-unresolved-key/.babelrc new file mode 100644 index 00000000..4f8903d2 --- /dev/null +++ b/test/fixtures/import-alias-unresolved-key/.babelrc @@ -0,0 +1,14 @@ +{ + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/import-alias-unresolved-key/code.js b/test/fixtures/import-alias-unresolved-key/code.js new file mode 100644 index 00000000..234ebbb9 --- /dev/null +++ b/test/fixtures/import-alias-unresolved-key/code.js @@ -0,0 +1,8 @@ +import { styled as s } from 'styled-components' +import { ThemeProvider as themeKey } from 'styled-components' + +let dynamic = 'div' + +const Comp = s.box`color: red;` +const Skipped = s[dynamic]`color: blue;` +const SkippedImport = s[themeKey]`color: green;` diff --git a/test/fixtures/import-alias-unresolved-key/output.js b/test/fixtures/import-alias-unresolved-key/output.js new file mode 100644 index 00000000..4397d32a --- /dev/null +++ b/test/fixtures/import-alias-unresolved-key/output.js @@ -0,0 +1,9 @@ +import { styled as s } from 'styled-components'; +import { ThemeProvider as themeKey } from 'styled-components'; +let dynamic = 'div'; +const Comp = s.box.withConfig({ + displayName: "code__Comp", + componentId: "sc-raskk5-0" +})(["color: red;"]); +const Skipped = s[dynamic]`color: blue;`; +const SkippedImport = s[themeKey]`color: green;`; diff --git a/test/fixtures/import-default-as-unresolved-key/.babelrc b/test/fixtures/import-default-as-unresolved-key/.babelrc new file mode 100644 index 00000000..4f8903d2 --- /dev/null +++ b/test/fixtures/import-default-as-unresolved-key/.babelrc @@ -0,0 +1,14 @@ +{ + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/import-default-as-unresolved-key/code.js b/test/fixtures/import-default-as-unresolved-key/code.js new file mode 100644 index 00000000..7e055735 --- /dev/null +++ b/test/fixtures/import-default-as-unresolved-key/code.js @@ -0,0 +1,5 @@ +import { default as s } from 'styled-components' + +let dynamic = 'div' + +const Comp = s[dynamic]`color: red;` diff --git a/test/fixtures/import-default-as-unresolved-key/output.js b/test/fixtures/import-default-as-unresolved-key/output.js new file mode 100644 index 00000000..385fc827 --- /dev/null +++ b/test/fixtures/import-default-as-unresolved-key/output.js @@ -0,0 +1,6 @@ +import { default as s } from 'styled-components'; +let dynamic = 'div'; +const Comp = s[dynamic].withConfig({ + displayName: "code__Comp", + componentId: "sc-gturdc-0" +})(["color: red;"]); diff --git a/test/fixtures/import-styled-alias-unresolved-key/.babelrc b/test/fixtures/import-styled-alias-unresolved-key/.babelrc new file mode 100644 index 00000000..4f8903d2 --- /dev/null +++ b/test/fixtures/import-styled-alias-unresolved-key/.babelrc @@ -0,0 +1,14 @@ +{ + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/import-styled-alias-unresolved-key/code.js b/test/fixtures/import-styled-alias-unresolved-key/code.js new file mode 100644 index 00000000..283d3762 --- /dev/null +++ b/test/fixtures/import-styled-alias-unresolved-key/code.js @@ -0,0 +1,7 @@ +import styled, { css } from 'styled-components' + +const s2 = styled +let dynamic = 'div' + +const Comp = s2[dynamic]`color: red;` +const Helper = styled[css]`color: blue;` diff --git a/test/fixtures/import-styled-alias-unresolved-key/output.js b/test/fixtures/import-styled-alias-unresolved-key/output.js new file mode 100644 index 00000000..312c7f4c --- /dev/null +++ b/test/fixtures/import-styled-alias-unresolved-key/output.js @@ -0,0 +1,8 @@ +import styled, { css } from 'styled-components'; +const s2 = styled; +let dynamic = 'div'; +const Comp = s2[dynamic].withConfig({ + displayName: "code__Comp", + componentId: "sc-aqb1s8-0" +})(["color: red;"]); +const Helper = styled[css]`color: blue;`; diff --git a/test/fixtures/import-styled-unresolved-key/.babelrc b/test/fixtures/import-styled-unresolved-key/.babelrc new file mode 100644 index 00000000..4f8903d2 --- /dev/null +++ b/test/fixtures/import-styled-unresolved-key/.babelrc @@ -0,0 +1,14 @@ +{ + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/import-styled-unresolved-key/code.js b/test/fixtures/import-styled-unresolved-key/code.js new file mode 100644 index 00000000..28ba4e17 --- /dev/null +++ b/test/fixtures/import-styled-unresolved-key/code.js @@ -0,0 +1,5 @@ +import styled from 'styled-components' + +let dynamic = 'div' + +const Comp = styled[dynamic]`color: red;` diff --git a/test/fixtures/import-styled-unresolved-key/output.js b/test/fixtures/import-styled-unresolved-key/output.js new file mode 100644 index 00000000..0c3a0dc2 --- /dev/null +++ b/test/fixtures/import-styled-unresolved-key/output.js @@ -0,0 +1,6 @@ +import styled from 'styled-components'; +let dynamic = 'div'; +const Comp = styled[dynamic].withConfig({ + displayName: "code__Comp", + componentId: "sc-7lj0lh-0" +})(["color: red;"]); diff --git a/test/fixtures/namespace-unresolved-key/.babelrc b/test/fixtures/namespace-unresolved-key/.babelrc new file mode 100644 index 00000000..4f8903d2 --- /dev/null +++ b/test/fixtures/namespace-unresolved-key/.babelrc @@ -0,0 +1,14 @@ +{ + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/namespace-unresolved-key/code.js b/test/fixtures/namespace-unresolved-key/code.js new file mode 100644 index 00000000..b90c9899 --- /dev/null +++ b/test/fixtures/namespace-unresolved-key/code.js @@ -0,0 +1,5 @@ +import * as ns from 'styled-components' + +let dynamic = 'div' + +const Comp = ns[dynamic]`color: red;` diff --git a/test/fixtures/namespace-unresolved-key/output.js b/test/fixtures/namespace-unresolved-key/output.js new file mode 100644 index 00000000..10226c19 --- /dev/null +++ b/test/fixtures/namespace-unresolved-key/output.js @@ -0,0 +1,6 @@ +import * as ns from 'styled-components'; +let dynamic = 'div'; +const Comp = ns[dynamic].withConfig({ + displayName: "code__Comp", + componentId: "sc-172imzu-0" +})(["color: red;"]); diff --git a/test/fixtures/require-computed-static-key/code.js b/test/fixtures/require-computed-static-key/code.js index 99bfc857..ebec05ab 100644 --- a/test/fixtures/require-computed-static-key/code.js +++ b/test/fixtures/require-computed-static-key/code.js @@ -5,6 +5,8 @@ const alias = name const tag = 'box' const css = 'div' let dynamic = 'css' +const bag = { key: 'css' } +const { key: fromBag } = bag const Skipped = sc[name]`color: red;` const SkippedAlias = sc[alias]`color: blue;` @@ -12,6 +14,8 @@ const SkippedTpl = sc[`css`]`color: green;` const SkippedDefault = sc.default[name]`color: orange;` const ViaDiv = sc[css]`color: purple;` const Tag = sc[tag]`color: pink;` -// A key that is not a const string stays decorated, even when its runtime -// value is a helper. `let` and `var` are not folded. +// A key that does not fold to a string stays plain on a require binding. const Dynamic = sc[dynamic]`color: black;` +const FromBag = sc[fromBag]`color: white;` +const FoldedHelper = sc[`${'css'}`]`color: teal;` +const FoldedTag = sc[`${'span'}`]`color: gray;` diff --git a/test/fixtures/require-computed-static-key/output.js b/test/fixtures/require-computed-static-key/output.js index 597244b7..ba637591 100644 --- a/test/fixtures/require-computed-static-key/output.js +++ b/test/fixtures/require-computed-static-key/output.js @@ -4,6 +4,12 @@ const alias = name; const tag = 'box'; const css = 'div'; let dynamic = 'css'; +const bag = { + key: 'css' +}; +const { + key: fromBag +} = bag; const Skipped = sc[name]`color: red;`; const SkippedAlias = sc[alias]`color: blue;`; const SkippedTpl = sc[`css`]`color: green;`; @@ -16,9 +22,11 @@ const Tag = sc[tag].withConfig({ displayName: "code__Tag", componentId: "sc-1v8m2kx-1" })(["color: pink;"]); -// A key that is not a const string stays decorated, even when its runtime -// value is a helper. `let` and `var` are not folded. -const Dynamic = sc[dynamic].withConfig({ - displayName: "code__Dynamic", +// A key that does not fold to a string stays plain on a require binding. +const Dynamic = sc[dynamic]`color: black;`; +const FromBag = sc[fromBag]`color: white;`; +const FoldedHelper = sc[`${'css'}`]`color: teal;`; +const FoldedTag = sc[`${'span'}`].withConfig({ + displayName: "code__FoldedTag", componentId: "sc-1v8m2kx-2" -})(["color: black;"]); +})(["color: gray;"]); diff --git a/test/fixtures/require-object-methods/code.js b/test/fixtures/require-object-methods/code.js index 364b3735..64794742 100644 --- a/test/fixtures/require-object-methods/code.js +++ b/test/fixtures/require-object-methods/code.js @@ -3,3 +3,13 @@ const sc = require('styled-components') const Comp = sc.default.div`color: red;` const has = sc.hasOwnProperty('css') const text = String(sc.toString()) +const Lookup = sc.__lookupGetter__`x` +const LookupSet = sc.__lookupSetter__`x` +const Proto = sc.__proto__`x` +const Bound = sc.bind`x` +const Applied = sc.apply`x` +const Called = sc.call`x` +const Len = sc.length`x` +const Named = sc.name`x` +const Args = sc.arguments`x` +const Caller = sc.caller`x` diff --git a/test/fixtures/require-object-methods/output.js b/test/fixtures/require-object-methods/output.js index 35e90bd9..c238a37b 100644 --- a/test/fixtures/require-object-methods/output.js +++ b/test/fixtures/require-object-methods/output.js @@ -5,3 +5,13 @@ const Comp = sc.default.div.withConfig({ })(["color: red;"]); const has = sc.hasOwnProperty('css'); const text = String(sc.toString()); +const Lookup = sc.__lookupGetter__`x`; +const LookupSet = sc.__lookupSetter__`x`; +const Proto = sc.__proto__`x`; +const Bound = sc.bind`x`; +const Applied = sc.apply`x`; +const Called = sc.call`x`; +const Len = sc.length`x`; +const Named = sc.name`x`; +const Args = sc.arguments`x`; +const Caller = sc.caller`x`; diff --git a/test/fixtures/require-reassign-before/code.js b/test/fixtures/require-reassign-before/code.js index 6f5b2a74..4bd8e05f 100644 --- a/test/fixtures/require-reassign-before/code.js +++ b/test/fixtures/require-reassign-before/code.js @@ -2,8 +2,7 @@ let sc = require('styled-components') const Before = sc.div`color: red;` -// Written above the assignment, so this stays decorated even though the -// function runs after `sc` has been replaced. +// The function can run after `sc` is replaced, so this stays plain. function writtenAbove() { return sc.span`color: blue;` } diff --git a/test/fixtures/require-reassign-before/output.js b/test/fixtures/require-reassign-before/output.js index 7b337ec6..293815d3 100644 --- a/test/fixtures/require-reassign-before/output.js +++ b/test/fixtures/require-reassign-before/output.js @@ -4,13 +4,9 @@ const Before = sc.div.withConfig({ componentId: "sc-m6xnle-0" })(["color: red;"]); -// Written above the assignment, so this stays decorated even though the -// function runs after `sc` has been replaced. +// The function can run after `sc` is replaced, so this stays plain. function writtenAbove() { - return sc.span.withConfig({ - displayName: "code", - componentId: "sc-m6xnle-1" - })(["color: blue;"]); + return sc.span`color: blue;`; } sc = { div: s => () => null, diff --git a/test/fixtures/require-reassign-loop/.babelrc b/test/fixtures/require-reassign-loop/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-reassign-loop/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-reassign-loop/code.js b/test/fixtures/require-reassign-loop/code.js new file mode 100644 index 00000000..8bd96778 --- /dev/null +++ b/test/fixtures/require-reassign-loop/code.js @@ -0,0 +1,32 @@ +let sc = require('styled-components') + +const First = sc.div`color: red;` + +for (let i = 0; i < 2; i++) { + const Comp = sc.div`color: blue;` + sc = { div: s => () => null } +} + +while (false) { + const InWhile = sc.div`color: blue;` + sc = { div: s => () => null } +} + +do { + const InDo = sc.div`color: blue;` + sc = { div: s => () => null } +} while (false) + +const list = [1] +for (const item of list) { + const InOf = sc.div`color: blue;` + sc = { div: s => () => null } +} + +const bag = { a: 1 } +for (const key in bag) { + const InIn = sc.div`color: blue;` + sc = { div: s => () => null } +} + +const After = sc.div`color: green;` diff --git a/test/fixtures/require-reassign-loop/output.js b/test/fixtures/require-reassign-loop/output.js new file mode 100644 index 00000000..05bc8670 --- /dev/null +++ b/test/fixtures/require-reassign-loop/output.js @@ -0,0 +1,40 @@ +let sc = require('styled-components'); +const First = sc.div.withConfig({ + displayName: "code__First", + componentId: "sc-yjtl8g-0" +})(["color: red;"]); +for (let i = 0; i < 2; i++) { + const Comp = sc.div`color: blue;`; + sc = { + div: s => () => null + }; +} +while (false) { + const InWhile = sc.div`color: blue;`; + sc = { + div: s => () => null + }; +} +do { + const InDo = sc.div`color: blue;`; + sc = { + div: s => () => null + }; +} while (false); +const list = [1]; +for (const item of list) { + const InOf = sc.div`color: blue;`; + sc = { + div: s => () => null + }; +} +const bag = { + a: 1 +}; +for (const key in bag) { + const InIn = sc.div`color: blue;`; + sc = { + div: s => () => null + }; +} +const After = sc.div`color: green;`; diff --git a/test/fixtures/require-styled-name-unresolved-key/.babelrc b/test/fixtures/require-styled-name-unresolved-key/.babelrc new file mode 100644 index 00000000..080e6ee1 --- /dev/null +++ b/test/fixtures/require-styled-name-unresolved-key/.babelrc @@ -0,0 +1,15 @@ +{ + "sourceType": "script", + "plugins": [ + [ + "../../../src", + { + "displayName": true, + "fileName": true, + "ssr": true, + "transpileTemplateLiterals": true, + "minify": false + } + ] + ] +} diff --git a/test/fixtures/require-styled-name-unresolved-key/code.js b/test/fixtures/require-styled-name-unresolved-key/code.js new file mode 100644 index 00000000..4c16ed76 --- /dev/null +++ b/test/fixtures/require-styled-name-unresolved-key/code.js @@ -0,0 +1,5 @@ +const styled = require('styled-components') + +let dynamic = 'div' + +const Comp = styled[dynamic]`color: red;` diff --git a/test/fixtures/require-styled-name-unresolved-key/output.js b/test/fixtures/require-styled-name-unresolved-key/output.js new file mode 100644 index 00000000..16c365c1 --- /dev/null +++ b/test/fixtures/require-styled-name-unresolved-key/output.js @@ -0,0 +1,6 @@ +const styled = require('styled-components'); +let dynamic = 'div'; +const Comp = styled[dynamic].withConfig({ + displayName: "code__Comp", + componentId: "sc-r7gp37-0" +})(["color: red;"]);