Rebuilding Tailwind‑Merge for Mini‑Program Compatibility: Lessons Learned

The article chronicles the author's deep technical journey of adapting Tailwind‑Merge to work within WeChat mini‑programs, detailing the obstacles posed by illegal class‑name characters, failed compile‑time workarounds, a complete rewrite of the escape utility, and the final release of a robust, configurable merge plugin.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rebuilding Tailwind‑Merge for Mini‑Program Compatibility: Lessons Learned

Background

In mini‑programs the wxml syntax does not allow characters such as !, [, ], # and others. Tailwind utilities that rely on tailwind-merge, class-variance-authority or tailwind-variants therefore cannot be used directly because the generated class strings contain those illegal characters.

Compile‑time conversion in weapp‑tailwindcss

weapp‑tailwindcss

solves the syntax restriction by converting illegal characters to safe placeholders during compilation. For example: bg-[#123456] → bg-_h123456_ The conversion is applied to wxml, js and wxss files. However, tailwind‑merge operates at runtime and expects the original class string, so the transformed placeholder breaks the merge logic.

Failed attempts

1. Tailwind‑merge plugin

An attempt was made to create a dedicated weapp‑tailwind‑merge plugin by extending tailwind‑merge with extendTailwindMerge and createTailwindMerge, providing custom escape hooks. This failed because tailwind‑merge relies on runtime string patterns that cannot be altered safely.

2. Compile‑time exemption

A second strategy wrapped functions such as twMerge, twJoin and cva with a compile‑time “exemption” that would skip escaping for literals and template strings. The helper cn performed:

export function cn(...inputs) {
  const result = twMerge(...inputs);
  return escape(result);
}

Complex cases involving variable references, template literals, and chained imports quickly broke this approach, exposing the limits of static analysis.

Why a full rewrite was required

Both approaches only postponed the failure. The core issue is that the merge logic must run at runtime while still being able to reverse the escape transformation.

Rewriting the escape utility

The original @weapp‑core/escape used a many‑to‑one mapping (e.g., '[' → '_') which made unescape impossible. It was replaced with a state‑machine based, one‑to‑one mapping where each illegal character receives a unique token:

export const MappingChars2String = {
  '[': '_b',
  ']': '_B',
  '(': '_p',
  ')': '_P',
  '#': '_h',
  '!': '_e',
  '/': '_f',
  '\\': '_r',
  '.': '_d',
  ':': '_c',
  '%': '_v',
  ',': '_m',
  "'": '_a',
  '"': '_q',
  '*': '_x',
  '&': '_n',
  '@': '_t',
  '{': '_k',
  '}': '_K',
  '+': '_u',
  ';': '_j',
  '<': '_l',
  '~': '_w',
  '=': '_z',
  '>': '_g',
  '?': '_Q',
  '^': '_y',
  '`': '_i',
  '|': '_o',
  '$': '_s',
} as const;

This guarantees that unescape(escape(input)) always returns the original string. Property‑based tests covering emojis, spaces and nested escapes were added to verify reversibility.

New merge core design

All entry points ( twMerge, twJoin, createTailwindMerge, extendTailwindMerge, cva, variants) now share a common transformer pipeline:

const normalized = transformers.unescape(clsx(...inputs));
return transformers.escape(fn(normalized));

The pipeline exposes escape and unescape hooks. A create() API allows users to toggle these steps, e.g. disabling escape for SSR, and a map option lets projects provide custom character mappings.

Example usage

import { create } from '@weapp-tailwindcss/merge';

// Disable both escape and unescape (useful for server‑side validation only)
const { twMerge: passthrough } = create({ escape: false, unescape: false });

Why the rewrite solves the problem

Runtime merge now receives the original, unescaped class string, performs conflict resolution, and then re‑escapes the result for mini‑program compatibility.

The reversible escape mapping eliminates the one‑to‑many loss that prevented unescape from working.

All Tailwind‑merge‑related APIs (including newer factories such as create() and variants) are automatically wrapped, so black‑list approaches are no longer needed.

Custom mappings and selective disabling make the solution adaptable to SSR, legacy codebases, and future Tailwind v4 arbitrary‑value syntax.

Release

The implementation is packaged as weapp‑[email protected] and @weapp‑tailwindcss/[email protected], marking the transition to a runtime‑centric approach.

References

Tailwind‑Merge plugin documentation: https://github.com/dcastil/tailwind-merge/blob/v3.3.1/docs/writing-plugins.md

NodePathWalker source: https://github.com/sonofmagic/weapp-tailwindcss/blob/main/packages/weapp-tailwindcss/src/js/NodePathWalker.ts

ModuleGraph source: https://github.com/sonofmagic/weapp-tailwindcss/blob/main/packages/weapp-tailwindcss/src/js/ModuleGraph.ts

ASTBuild ToolsPlugin DevelopmentWeChat MiniProgramTailwindCSS
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.