From 94f765a29f5ba0c537b2f8b849e5cf77846f8940 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 7 Oct 2026 16:53:17 +0200 Subject: [PATCH 1/2] fix: respect consumer environment and silence SSR reuse --- .changeset/quiet-lemons-bloom.md | 7 ++++ scripts/check-built-entries.mjs | 61 ++++++++++++++++++++++++++++++++ src/config.test.ts | 38 ++++++++++++++++++++ src/config.ts | 4 --- src/pipeline/parseStateKey.ts | 4 +-- src/utils/is-dev-env.ts | 5 ++- tsdown.config.ts | 3 ++ 7 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 .changeset/quiet-lemons-bloom.md diff --git a/.changeset/quiet-lemons-bloom.md b/.changeset/quiet-lemons-bloom.md new file mode 100644 index 00000000..64348ef8 --- /dev/null +++ b/.changeset/quiet-lemons-bloom.md @@ -0,0 +1,7 @@ +--- +'@tenphi/tasty': patch +--- + +Keep development diagnostics and debug instrumentation disabled in production +unless TASTY_DEBUG is enabled. Reusing server-rendered global styles no longer +emits a warning. diff --git a/scripts/check-built-entries.mjs b/scripts/check-built-entries.mjs index ae179a1c..47e349a2 100644 --- a/scripts/check-built-entries.mjs +++ b/scripts/check-built-entries.mjs @@ -7,6 +7,8 @@ * points catches that class of packaging failure. */ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; import { readFileSync, readdirSync, statSync } from 'node:fs'; import { basename, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -123,6 +125,65 @@ for (const entry of entries) { await import(new URL(`../dist/${entry}`, import.meta.url)); } +// Source tests cannot catch NODE_ENV being folded to "development" while the +// package is built. Load the emitted runtime in fresh consumer processes. +for (const [mode, debug, expectedDevMode] of [ + ['production', false, false], + ['test', false, false], + ['development', false, true], + ['production', true, true], +]) { + const result = JSON.parse( + execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` + const warnings = []; + console.warn = (...args) => warnings.push(args.join(' ')); + if (${debug}) { + globalThis.window = { + localStorage: { getItem: () => 'true' }, + }; + } + const { configure, getConfig, computeStyles } = + await import(${JSON.stringify(new URL('../dist/core/index.js', import.meta.url).href)}); + configure({ recipes: { existing: { display: 'block' } } }); + computeStyles({ recipe: 'missing-recipe' }); + const recipeWarnings = [...warnings]; + warnings.length = 0; + const { tastyStatic } = + await import(${JSON.stringify(new URL('../dist/static/index.js', import.meta.url).href)}); + tastyStatic({ display: 'block' }); + console.log(JSON.stringify({ + devMode: getConfig().devMode, + recipeWarnings, + staticWarnings: warnings, + })); + `, + ], + { encoding: 'utf8', env: { ...process.env, NODE_ENV: mode } }, + ), + ); + + const context = `NODE_ENV=${mode}, TASTY_DEBUG=${debug}`; + assert.equal(result.devMode, expectedDevMode, context); + assert.equal(result.recipeWarnings.length, expectedDevMode ? 1 : 0, context); + assert.equal( + result.staticWarnings.length, + mode === 'production' ? 0 : 1, + context, + ); + if (expectedDevMode) { + assert.match( + result.recipeWarnings[0], + /Recipe "missing-recipe" not found/, + context, + ); + } +} + assertGraphExcludes('static/index.js', [ 'debug-', 'dsl-', diff --git a/src/config.test.ts b/src/config.test.ts index 2f949e91..b53cbc08 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -268,3 +268,41 @@ describe('configure() namePrefix', () => { ); }); }); + +describe('SSR global styles reuse', () => { + it('reuses server globals without warning, including in debug mode', async () => { + const previousDebug = localStorage.getItem('TASTY_DEBUG'); + localStorage.setItem('TASTY_DEBUG', 'true'); + vi.resetModules(); + const config = await import('./config'); + config.resetConfig(); + + const ssrStyle = document.createElement('style'); + ssrStyle.setAttribute('data-tasty-ssr', ''); + ssrStyle.textContent = ':root { --hydration-gap: 8px; }'; + document.head.appendChild(ssrStyle); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { + /* noop */ + }); + + try { + config.configure({ tokens: { '$hydration-gap': '8px' } }); + expect(config.getConfig().devMode).toBe(true); + config.markStylesGenerated(); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(config.isConfigLocked()).toBe(true); + const css = Array.from(document.styleSheets) + .flatMap((sheet) => Array.from(sheet.cssRules, (rule) => rule.cssText)) + .join('\n'); + expect(css.match(/--hydration-gap:/g)).toHaveLength(1); + } finally { + warnSpy.mockRestore(); + ssrStyle.remove(); + config.resetConfig(); + if (previousDebug === null) localStorage.removeItem('TASTY_DEBUG'); + else localStorage.setItem('TASTY_DEBUG', previousDebug); + vi.resetModules(); + } + }); +}); diff --git a/src/config.ts b/src/config.ts index 6733feba..c2f00681 100644 --- a/src/config.ts +++ b/src/config.ts @@ -658,10 +658,6 @@ export function markStylesGenerated(): void { typeof document !== 'undefined' && document.querySelector('[data-tasty-ssr]') ) { - warnOnce( - 'ssr-globals-skip', - '[Tasty] SSR styles detected — skipping client-side global CSS injection to avoid duplicates.', - ); return; } diff --git a/src/pipeline/parseStateKey.ts b/src/pipeline/parseStateKey.ts index 2a4f41e7..4b7a0909 100644 --- a/src/pipeline/parseStateKey.ts +++ b/src/pipeline/parseStateKey.ts @@ -924,8 +924,8 @@ export function parseStateKey( // inside `:is(...)` / `:has(...)` / `:not(...)` / `:where(...)`. // // Gated behind `isDevEnv()` because this is a developer-only aid. - // `isDevEnv()` uses bracket notation on `process.env` to survive - // tasty's own bundling and also returns `false` under `NODE_ENV=test` + // Tasty's package build preserves the environment check in `isDevEnv()`, + // which also returns `false` under `NODE_ENV=test` // / `NODE_ENV=production`, so the warning only fires in real dev. if (isDevEnv()) { INTERNAL_PSEUDO_PATTERN.lastIndex = 0; diff --git a/src/utils/is-dev-env.ts b/src/utils/is-dev-env.ts index 2cb16715..f93ac06a 100644 --- a/src/utils/is-dev-env.ts +++ b/src/utils/is-dev-env.ts @@ -1,6 +1,6 @@ /** * Check if we're in a development environment at runtime - * Uses bracket notation to avoid bundler compilation + * The package build preserves NODE_ENV for the consuming app's bundler. * Also checks for TASTY_DEBUG localStorage setting */ export function isDevEnv(): boolean { @@ -23,7 +23,6 @@ export function isDevEnv(): boolean { // Check NODE_ENV for Node.js environments if (typeof process === 'undefined') return false; - // Use bracket notation to avoid bundler replacement - const nodeEnv = process.env?.['NODE_ENV']; + const nodeEnv = process.env.NODE_ENV; return nodeEnv !== 'test' && nodeEnv !== 'production'; } diff --git a/tsdown.config.ts b/tsdown.config.ts index bec8de6f..d48b1d8e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -44,6 +44,9 @@ const sharedConfig = { 'next/navigation', ], platform: 'browser', + // Preserve the consumer's environment; the browser preset otherwise folds + // NODE_ENV to "development" while publishing the library. + define: { 'process.env.NODE_ENV': 'process.env.NODE_ENV' }, target: 'es2022', sourcemap: true, } satisfies UserConfig; From e939c2fdcc2b8204ae48ae2171372d84156d1fbf Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 7 Oct 2026 17:04:04 +0200 Subject: [PATCH 2/2] fix(build): retain existing runtime debug contracts --- .changeset/quiet-lemons-bloom.md | 6 ++--- scripts/check-built-entries.mjs | 37 +++++++++++++++++++++++++++++++ src/config.test.ts | 38 -------------------------------- src/config.ts | 4 ++++ src/pipeline/parseStateKey.ts | 4 ++-- src/utils/is-dev-env.ts | 5 +++-- 6 files changed, 49 insertions(+), 45 deletions(-) diff --git a/.changeset/quiet-lemons-bloom.md b/.changeset/quiet-lemons-bloom.md index 64348ef8..55fd57e1 100644 --- a/.changeset/quiet-lemons-bloom.md +++ b/.changeset/quiet-lemons-bloom.md @@ -2,6 +2,6 @@ '@tenphi/tasty': patch --- -Keep development diagnostics and debug instrumentation disabled in production -unless TASTY_DEBUG is enabled. Reusing server-rendered global styles no longer -emits a warning. +Preserve environment checks in the published package so production consumers +do not enable development diagnostics or debug instrumentation by default. +Keep the TASTY_DEBUG override available in production. diff --git a/scripts/check-built-entries.mjs b/scripts/check-built-entries.mjs index 47e349a2..9b9f5bcf 100644 --- a/scripts/check-built-entries.mjs +++ b/scripts/check-built-entries.mjs @@ -12,6 +12,8 @@ import { execFileSync } from 'node:child_process'; import { readFileSync, readdirSync, statSync } from 'node:fs'; import { basename, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { runInNewContext } from 'node:vm'; +import { build } from 'esbuild'; const distDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'dist'); @@ -184,6 +186,41 @@ for (const [mode, debug, expectedDevMode] of [ } } +// The original runtime debug switch must also survive a consuming app's +// production bundling and minification, with or without a process shim. +const consumerBundle = await build({ + stdin: { + contents: ` + import { configure, getConfig, computeStyles } from './dist/core/index.js'; + configure({ recipes: { existing: { display: 'block' } } }); + computeStyles({ recipe: 'missing-recipe' }); + globalThis.tastyConfig = getConfig(); + `, + resolveDir: dirname(distDir), + }, + bundle: true, + platform: 'browser', + format: 'iife', + write: false, + minify: true, + define: { 'process.env.NODE_ENV': '"production"' }, +}); + +for (const processShim of [false, true]) { + for (const debug of [false, true]) { + const warnings = []; + const sandbox = { + ...(processShim ? { process: { env: { NODE_ENV: 'production' } } } : {}), + window: { localStorage: { getItem: () => (debug ? 'true' : null) } }, + console: { warn: (...args) => warnings.push(args.join(' ')) }, + }; + runInNewContext(consumerBundle.outputFiles[0].text, sandbox); + const context = `Production browser, process shim=${processShim}, TASTY_DEBUG=${debug}`; + assert.equal(sandbox.tastyConfig.devMode, debug, context); + assert.equal(warnings.length, debug ? 1 : 0, context); + } +} + assertGraphExcludes('static/index.js', [ 'debug-', 'dsl-', diff --git a/src/config.test.ts b/src/config.test.ts index b53cbc08..2f949e91 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -268,41 +268,3 @@ describe('configure() namePrefix', () => { ); }); }); - -describe('SSR global styles reuse', () => { - it('reuses server globals without warning, including in debug mode', async () => { - const previousDebug = localStorage.getItem('TASTY_DEBUG'); - localStorage.setItem('TASTY_DEBUG', 'true'); - vi.resetModules(); - const config = await import('./config'); - config.resetConfig(); - - const ssrStyle = document.createElement('style'); - ssrStyle.setAttribute('data-tasty-ssr', ''); - ssrStyle.textContent = ':root { --hydration-gap: 8px; }'; - document.head.appendChild(ssrStyle); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { - /* noop */ - }); - - try { - config.configure({ tokens: { '$hydration-gap': '8px' } }); - expect(config.getConfig().devMode).toBe(true); - config.markStylesGenerated(); - - expect(warnSpy).not.toHaveBeenCalled(); - expect(config.isConfigLocked()).toBe(true); - const css = Array.from(document.styleSheets) - .flatMap((sheet) => Array.from(sheet.cssRules, (rule) => rule.cssText)) - .join('\n'); - expect(css.match(/--hydration-gap:/g)).toHaveLength(1); - } finally { - warnSpy.mockRestore(); - ssrStyle.remove(); - config.resetConfig(); - if (previousDebug === null) localStorage.removeItem('TASTY_DEBUG'); - else localStorage.setItem('TASTY_DEBUG', previousDebug); - vi.resetModules(); - } - }); -}); diff --git a/src/config.ts b/src/config.ts index c2f00681..6733feba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -658,6 +658,10 @@ export function markStylesGenerated(): void { typeof document !== 'undefined' && document.querySelector('[data-tasty-ssr]') ) { + warnOnce( + 'ssr-globals-skip', + '[Tasty] SSR styles detected — skipping client-side global CSS injection to avoid duplicates.', + ); return; } diff --git a/src/pipeline/parseStateKey.ts b/src/pipeline/parseStateKey.ts index 4b7a0909..2a4f41e7 100644 --- a/src/pipeline/parseStateKey.ts +++ b/src/pipeline/parseStateKey.ts @@ -924,8 +924,8 @@ export function parseStateKey( // inside `:is(...)` / `:has(...)` / `:not(...)` / `:where(...)`. // // Gated behind `isDevEnv()` because this is a developer-only aid. - // Tasty's package build preserves the environment check in `isDevEnv()`, - // which also returns `false` under `NODE_ENV=test` + // `isDevEnv()` uses bracket notation on `process.env` to survive + // tasty's own bundling and also returns `false` under `NODE_ENV=test` // / `NODE_ENV=production`, so the warning only fires in real dev. if (isDevEnv()) { INTERNAL_PSEUDO_PATTERN.lastIndex = 0; diff --git a/src/utils/is-dev-env.ts b/src/utils/is-dev-env.ts index f93ac06a..2cb16715 100644 --- a/src/utils/is-dev-env.ts +++ b/src/utils/is-dev-env.ts @@ -1,6 +1,6 @@ /** * Check if we're in a development environment at runtime - * The package build preserves NODE_ENV for the consuming app's bundler. + * Uses bracket notation to avoid bundler compilation * Also checks for TASTY_DEBUG localStorage setting */ export function isDevEnv(): boolean { @@ -23,6 +23,7 @@ export function isDevEnv(): boolean { // Check NODE_ENV for Node.js environments if (typeof process === 'undefined') return false; - const nodeEnv = process.env.NODE_ENV; + // Use bracket notation to avoid bundler replacement + const nodeEnv = process.env?.['NODE_ENV']; return nodeEnv !== 'test' && nodeEnv !== 'production'; }