Skip to content

Rspack Support for next-yak #507

@jantimon

Description

@jantimon

Rspack joins the Next.js ecosystem so I guess we should add support for rspack

To understand the options we have for rspack, it helps to know how the other bundlers solve the same problem. The core challenge is always the same: the yak SWC plugin transforms a .tsx file and embeds CSS as comments in the JS output. We then need to extract that CSS into a separate module. The tricky part is that the class name hashes must match exactly between the JS runtime and the generated CSS, so ideally both sides come from the same SWC transformation.

Webpack solves this with this.loadModule(). The CSS loader calls this.loadModule(this.resourcePath) which re-processes the file through webpack's loader pipeline (including SWC). It returns the transformed source as a string, we extract the CSS from it. One SWC pass, hashes always match.

Turbopack takes a different route. The SWC plugin is configured to emit CSS directly as base64 data URL imports (import "data:text/css;base64,...") in the transformed JS. No separate CSS loader needed, turbopack picks up the data URLs natively.

Vite has neither loadModule nor a loader pipeline to hook into. So the vite plugin reads the source file and calls swcTransform() directly a second time with the same config, then extracts CSS from that. It works, but SWC runs twice per file and hash consistency depends on both calls using identical config.

The problem with rspack

Rspack is mostly webpack-compatible but is missing the two APIs we rely on:

  • this.loadModule() is not implemented. Calling it throws TypeError: this.loadModule is not a function
  • Data URL imports (import "data:text/css;base64,...") are not supported out of the box

Rspack does have this.importModule(), but that one compiles and evaluates the module. Since we're dealing with React component files, that would try to execute React code at build time which obviously doesn't work. You could work around that with an inline raw loader (see Option A), but importModule also recompiles the module in Node.js mode to make it evaluatable, so it's a separate compilation from rspack's normal browser-targeted build anyway

Also Source maps are passed to loaders as JSON strings instead of objects, which breaks SWC. Fixable by parsing/serializing at the boundaries, but something to be aware of

So we need a different approach. We explored two options:

Option A: importModule + inline raw loader

The idea here is to work around the importModule evaluation problem. If we pipe the file through a tiny raw loader first, the module's export becomes just a string instead of React code.

The raw loader is literally 3 lines:

export default function rawLoader(source: string) {
  return `export default ${JSON.stringify(source)}`;
}

Then in the CSS extraction loader:

const { default: source } = await this.importModule(
  `${pathToRawLoader}!${this.resourcePath}`
);
const css = extractCss(source, transpilationMode);

What happens under the hood:

  1. importModule triggers a separate compilation of the .tsx file in Node.js mode
  2. Rspack's loaders (including SWC with the yak plugin) run as part of that compilation
  3. The inline raw loader wraps the output as export default "<source>"
  4. importModule evaluates the module and returns the string

Important caveat: since importModule compiles in Node.js mode, this is a separate SWC pass from the browser-targeted one. The yak plugin config should be the same, so hashes should match, but it's not guaranteed by the pipeline itself. This is the same situation as Vite

Option B: Reuse the turbo-loader + experiments.css

This approach was implemented and tested in yak-worktree-2. Instead of replicating the webpack flow, it reuses the existing turbo-loader (originally built for turbopack) and enables rspack's native CSS support to handle the data URL imports.

How it works:

  1. Detect rspack via the NEXT_RSPACK env var (set by withRspack())
  2. Register the turbo-loader as an enforce: "pre" rule for source files
  3. The turbo-loader runs SWC directly with the yak plugin, extracts CSS, and outputs JS with import "data:text/css;base64,..."
  4. Enable experiments.css = true and add a rule for { scheme: "data", mimetype: "text/css", type: "css/auto" } so rspack handles those data URL imports natively
source.tsx -> turbo-loader (runs SWC + extracts CSS)
                   |
    JS with data:text/css;base64 import
                   |
    rspack's built-in SWC (handles remaining JSX)
                   |
    rspack's native CSS pipeline (experiments.css)

A few things to note:

  • Config wrapper order matters: withYak(withRspack(nextConfig)) because withRspack must run first to clean up the TURBOPACK env var before withYak checks it
  • The turbo-loader preserves JSX (react.runtime: "preserve"), so rspack's built-in SWC still handles JSX->JS compilation after
  • Source maps needed some fixing because rspack passes string source maps where SWC expects objects

Comparison

Both options run SWC twice per file and both rely on keeping the yak plugin config in sync between the two passes for hash consistency. Neither can reuse rspack's browser-targeted SWC pass directly (unlike webpack's loadModule which does exactly that).

Option A: importModule + raw loader Option B: turbo-loader + experiments.css
SWC runs per file Twice (Node.js recompilation) Twice (turbo-loader + rspack's built-in)
Hash consistency Depends on keeping SWC config in sync Depends on keeping SWC config in sync
New code needed ~3-line raw loader + rspack CSS loader Only addYakRspack() in withYak
CSS pipeline Next.js's CSS pipeline rspack's native CSS pipeline

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions