# Replacing Babel with OXC in Storybook

- Author: Dongri Shin
- Published: 2026-10-25
- Page: https://www.shindongri.dev/en/blog/261025

> A small PR to remove duplicate parsing grew into replacing Storybook's CSF processing layer. This is a record of why the direction changed during the RFC, the regressions a differential review uncovered, and how they were fixed.

"I believe we'll have very little appetite for owning two indexers instead of one."

That was not the answer I expected to my proposal to speed up Storybook's CSF indexing with OXC. I thought keeping the existing Babel implementation around was the safe choice. The maintainers saw it the other way: that choice would raise the maintenance cost going forward.

The work started at the end of September 2026 as a fix for a simple problem: the same story file was being parsed twice. I opened [PR #36468](https://github.com/storybookjs/storybook/pull/36468) to remove the duplicate parse, and then proposed a fast indexing path built on OXC in [Discussion #36467](https://github.com/storybookjs/storybook/discussions/36467). After some discussion, though, the goal shifted from optimizing indexing to removing Babel and Recast from the CSF processing layer altogether.

Expanding the scope introduced challenges I hadn't anticipated. Code that passed the existing tests could still change the value of a template literal or the meaning of an array config. This post covers the choices I made while working on [PR #36613](https://github.com/storybookjs/storybook/pull/36613), and what I missed before review caught it.

| Date | Work |
| --- | --- |
| Sep 27 | Proposed [Discussion #36467](https://github.com/storybookjs/storybook/discussions/36467), submitted [PR #36468](https://github.com/storybookjs/storybook/pull/36468) |
| Oct 6 | Submitted [PR #36613](https://github.com/storybookjs/storybook/pull/36613) |
| Oct 13 | Duplicate-parsing PR merged |
| Oct 15–20 | Differential review, regression fixes, joint work with the maintainers |
| Oct 20–21 | OXC migration PR and the split-out [PR #36861](https://github.com/storybookjs/storybook/pull/36861) merged |

## 1. It started with duplicate parsing

In Storybook, stories are written in CSF (Component Story Format) files. For example:

```tsx
import type { Meta, StoryObj } from '@storybook/react';

import { Button } from './Button';

const meta = {
  component: Button,
  title: 'Example/Button',
} satisfies Meta<typeof Button>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Primary: Story = {
  args: {
    label: 'Button',
  },
};
```

Storybook parses this file to extract each story's ID, name, and metadata, and builds an index from them. The same CSF processing logic (`csf-tools`) is also used to show the original source in Docs and to turn stories into Vitest tests. At the time, all of this ran on Babel.

### Most of the transform time was parsing

The first place I looked was the CSF plugin in `addon-docs`. When I timed the plugin's transform across the repository's 706 story files, about 92% of the total time went to Babel parsing. The transform logic itself was light; nearly all the time was spent "turning code into an AST."

Reading the code, I found out why. In Vite, this plugin runs in the `pre` phase, so it usually receives the original story file as is. Yet the plugin was parsing the same source with Babel twice:

- once as the target to attach Docs information to
- once as a "clean original" to read JSDoc descriptions and the original source (`originalSource`) from

```mermaid
flowchart LR
  subgraph Before["Before: parsed twice"]
    A1[story.tsx] --> P1[Babel parse] --> E1[enrich target]
    A1 --> P2[Babel parse] --> S1[JSDoc / originalSource]
  end
  subgraph After["After: parsed once"]
    A2[story.tsx] --> P3[Babel parse] --> E2[enrich target]
    P3 --> S2[JSDoc / originalSource]
  end
  Before ~~~ After
```

If there was no need to build two ASTs from the same input, the parsing cost could drop accordingly.

### What I checked before removing the second parse

Still, the two ASTs couldn't be merged unconditionally. The default enrichment only appends statements to the end of the file and doesn't modify existing story nodes, so as long as the code matches the file on disk, the parsed result can double as the original.

The user-registered `experimental_enrichCsf` hook, on the other hand, can read the original during the transform. React's code-examples enricher actually did exactly that. So I made the reuse conditional on both of the following:

- the code passed in is identical to the original file on disk
- no `experimental_enrichCsf` hook is registered

In Webpack, the loader runs after babel/swc, so the code never matches the file on disk; I left that path unchanged.

### Runtime was cut in half

[PR #36468](https://github.com/storybookjs/storybook/pull/36468) delivered the reduction I expected:

- Plugin transform time: about 8.4s -> 4.2s (average of 5 runs)
- Generated code and source maps identical to before for all 706 files

During review, a maintainer separately confirmed that the code and source maps were byte-for-byte identical across 705 story files in several transform modes, and the PR was merged on October 13.

In the OXC migration that followed, I kept submitting comparisons against the existing output alongside the performance numbers.

## 2. The RFC: "Not replacing Babel"

Once the duplicate parse was gone, I wanted to try swapping out the parser itself.

The candidate was OXC, a JavaScript/TypeScript toolchain written in Rust. Storybook was already using `oxc-parser` to build the module graph for change detection ([#34625](https://github.com/storybookjs/storybook/pull/34625)). Not having to add a new dependency was a big deal.

In the initial [RFC #36467](https://github.com/storybookjs/storybook/discussions/36467), I limited the scope of the change to indexing:

- A fast path that indexes only statically analyzable story files with OXC
- Unsupported shapes return `null` and fall back to the existing Babel `CsfFile`
- Non-goal: **replacing Babel/`CsfFile`.** Transforms and writes keep using Babel
- The alternative I considered, "fully replacing Babel in CsfFile," was rejected as **much larger and riskier**

The PoC results looked like this:

| Item | Result |
|---|---|
| Files handled by the fast path | 680 of 706 (96.3%) |
| Mismatches against Babel output | 0 |
| Internal UI `index.json` (1,768 entries) | Byte-for-byte identical |
| Isolated CSF indexing benchmark | ~4.5x |
| `storybook index` end-to-end | 7.97s -> 5.95s (~25% reduction) |

I also made the fallback for the remaining 26 files deterministic, so the same files would drop out for the same reasons on every run. Under risks, I listed "drift between the two paths" and "the cost of maintaining one more read-only code path."

### The reply: don't maintain two indexers

The first response was not what I expected. Maintainer Sidnioulz [replied](https://github.com/storybookjs/storybook/discussions/36467#discussioncomment-18662725):

> I believe we'll have very little appetite for owning two indexers instead of one, though. … My personal opinion is that "Replacing Babel in CsfFile entirely" would be a great goal for this RFC. It's definitely a bigger piece of work but it's also a bigger net benefit and it avoids having to still preserve Babel!

The "drift between the two paths" I had written down as a risk, the maintainer saw not as a risk but as a reason the approach couldn't be accepted. Once CSF 4 becomes the default in Storybook 11, PRs would pile up against the static extraction code used for the index, and each time the two implementations would need to be kept in sync.

I didn't expand the scope right away. First, I [asked whether anyone was already working on replacing Babel in `CsfFile`](https://github.com/storybookjs/storybook/discussions/36467#discussioncomment-18689339). The bigger the task, the more important I thought it was to make sure it didn't overlap with someone else's work.

Then Michael Shilman, who created Storybook, [wrote that from a product standpoint it made complete sense](https://github.com/storybookjs/storybook/discussions/36467#discussioncomment-18709871), and added: "At that point can we just remove Babel entirely? If not, what's blocking us? I don't want to get into a situation where we're supporting both long term."

Sidnioulz [asked me to go ahead with a PoC](https://github.com/storybookjs/storybook/discussions/36467#discussioncomment-18761367), noting that there might be requirements that weren't visible at first, and that the team would leave them in the thread as they came to mind.

At first, I thought touching as little of the existing code as possible was the safe option. In the PoC, in fact, OXC handled only 680 of the 706 files and left the rest to Babel. That approach meant fewer edge cases to deal with right away and seemed to carry less risk of breaking existing behavior.

But every time an indexing rule was added, both implementations would have to change together. The conditions for handing files OXC couldn't process over to Babel would also need ongoing maintenance. The immediate PR gets smaller, but the problem of two code paths drifting apart never goes away.

So I changed my criterion. **What mattered was not how small I could make this change, but whether two implementations would have to be maintained after it was merged.** Fully replacing Babel meant more upfront work and a heavier verification burden, but if it succeeded, the CSF processing rules would live in one place. That didn't mean getting rid of Babel for compiling user code, though. I limited the scope to the CSF processing path centered on `csf-tools`.

```mermaid
flowchart LR
  subgraph RFC["Original proposal"]
    direction TB
    S1[Story file] --> F{Can the OXC<br/>fast path<br/>handle it?}
    F -- Yes --> I1[Index]
    F -- "No (26 files)" --> B1[Babel CsfFile] --> I1
    W1["Docs · Vitest<br/>transforms,<br/>code edits"] --> B2[Babel + Recast]
  end
  subgraph Final["Final structure after<br/>maintainer discussion"]
    direction TB
    S2[Story & config files] --> O[oxc-parser]
    O --> T["csf-tools<br/>indexing · Docs<br/>Vitest transforms<br/>code edits"]
    T --> M[SourceEditor + MagicString]
  end
  RFC -- "Don't maintain<br/>two implementations" --> Final
```

So before starting the implementation, I [laid out the scope and measurement plan again and posted it to the Discussion](https://github.com/storybookjs/storybook/discussions/36467#discussioncomment-18768107). The goal was to give the team a chance to flag problems early.

- What gets replaced: the read path, the write path, transforms, and one external consumer to validate the API. The riskiest part, the write path, goes first.
- How it's measured: the existing test suite passes unchanged, every snapshot change is called out explicitly, and `index.json` is verified to be byte-for-byte identical.
- Known limitations: formatting of inserted code, no Flow support, and breaking changes from exposing the AST.
- Out of scope: Babel as a compiler for user code (`babel-loader` and the like).

I shared the [PoC results and the PR link](https://github.com/storybookjs/storybook/discussions/36467#discussioncomment-18768730) in the same thread, and Michael Shilman replied, ["These are amazing numbers"](https://github.com/storybookjs/storybook/discussions/36467#discussioncomment-18797738).

## 3. Moving the work Babel used to do

Removing Babel wasn't just a matter of swapping the parser. `csf-tools` actually relied on Babel for three things:

| What Babel did | After the replacement |
|---|---|
| `@babel/parser` - parsing | `oxc-parser` (ESTree) |
| `@babel/traverse` - `NodePath`, `scope.getBinding()` | A hand-written scope analyzer (`estree/scope.ts`) |
| `recast` - reprinting after AST edits | A span-based text editor (`estree/editor.ts`, MagicString) |

```mermaid
flowchart LR
  BP["Parsing<br/>@babel/parser"] -- Replaced --> OP["oxc-parser<br/>(ESTree)"]
  BT["Scope analysis<br/>@babel/traverse"] -- Built in-house --> OS["estree/scope.ts"]
  BR["Code edits<br/>Recast reprint"] -- Built in-house --> OE["estree/editor.ts<br/>SourceEditor + MagicString"]
```

Porting scope analysis and code editing took far more time than switching the parsing API.

### Implementing scope analysis from scratch

OXC's JavaScript API doesn't expose scope information. Yet Storybook relied on Babel's scope analysis all over the place, taking it for granted. The server-code removal plugin for the TanStack framework is a good example: in `createServerFn().handler(fn)`, if `fn` is an identifier that's used only once, the plugin deletes its declaration.

In Babel, it took three lines:

```ts
// Before: Babel NodePath

if (t.isIdentifier(handlerArg)) {
  const binding = path.scope.getBinding(handlerArg.name);
  if (binding && binding.referencePaths.length === 1) {
    binding.path.remove();
  }
}
```

That single `binding.path.remove()` line hid a lot. What needs to be deleted depends on whether the declaration is a `function` or a `const`, whether it's one of several declarators as in `const a = 1, b = 2`, and whether it sits inside an `export` statement. Babel handled all of that for you at the AST level.

Moving to OXC, I had to handle the deletion conditions myself. After confirming there's only one reference, a function or class gets its whole declaration removed. Variable declarations are a bit more involved: removing just `a` from `const a = 1, b = 2` means dealing with the comma too, and a declaration wrapped in `export` has yet another boundary to consider. In effect, I was spelling out in code the behavior that had been sitting behind Babel's one-line `binding.path.remove()`.

`scope.ts` became an 850-line analyzer that reproduces the semantics of Babel's `getBinding` / `constant` / `referencePaths`. I verified it with differential comparison: 99,828 bindings from 3,717 files in the repository were compared against Babel, with zero mismatches. I also pinned Babel-specific rules in `scope.test.ts`, for example: type references count as references, `export` counts as a reference, and a function inside a loop body is not constant.

Even so, review found gaps. I'll come back to that in section 5.

### Editing the original text instead of using Recast

Recast reprints the entire file after the AST is modified. It mostly preserves the original formatting, but not perfectly. During review, we actually found a bug on the existing `next` branch: when Recast reprinted a file, it changed `\` line continuations and template literals in sibling nodes it hadn't even touched.

The alternative was to fix only the necessary ranges in the original string, instead of modifying the AST and printing it again. `SourceEditor` holds the original text and uses MagicString to record insertions, deletions, and replacements on specific spans. Since unedited parts are never reprinted, the original whitespace and line breaks are preserved exactly.

This choice came with a clear cost. With Recast, when you deleted or moved a node, the printing step took care of the surrounding syntax to some extent. With span editing, you have to decide for yourself about the comments in front of a node being deleted, the commas next to array elements, and the outer boundaries of parentheses. It's better for preserving the original formatting, but string manipulation that ignores syntax is actually more dangerous. **I chose MagicString not because editing was easier, but so that code I didn't change would stay untouched.**

In exchange, I had to do what Recast used to do: match the indentation of inserted code to its surroundings, follow the quote style, and get the boundaries of commas, parentheses, and comments exactly right. Changing spans and building a safe code editor turned out to be entirely different problems, and that difference showed up plainly in review.

## 4. A first PR that passed the tests

I opened [PR #36613](https://github.com/storybookjs/storybook/pull/36613) on October 6. The first measurements, on 587 story files, showed 5.1x for indexing, 4.8x for Docs `enrichCsf`, and 3.2x for `vitestTransform`, with zero differences from Babel's output in all three.

Working from a fork, I couldn't assign reviewers or add labels, so I [asked the maintainers for review and the necessary labels in a PR comment](https://github.com/storybookjs/storybook/pull/36613#issuecomment-6137152462). I split the commits by area so they could be read in order, and noted in the description that, once approved, it could be merged as three stacked PRs.

Early in the review, Kasper Peulen [asked](https://github.com/storybookjs/storybook/pull/36613#issuecomment-6163358568):

> How do you get rid of recast? And keep the indentation? I heard it can be done with magic-string

I [answered](https://github.com/storybookjs/storybook/pull/36613#issuecomment-6165261022) that inserted code copies the indentation of the line it goes into (`reindent`) and follows the file's quote style, and that the save-story and codemod snapshot tests passing unchanged was the best evidence that formatting was preserved.

At the time, I treated passing the existing snapshots as fairly strong evidence. But it guaranteed nothing about inputs the snapshots didn't cover. In particular, I missed cases like whitespace or parentheses inside strings, where the code still parses fine but its meaning changes. The next round of review revealed exactly that.

## 5. Three regressions found in review

The next day, [Kasper's review](https://github.com/storybookjs/storybook/pull/36613#pullrequestreview-5531832939) came in. It opened by saying the direction was right and the read side was impressive, but the verdict was Changes requested.

Kasper fed the same inputs into the compiled `core/dist` of `next` (Babel) and of my branch (OXC), and compared the results.

Kasper tested a substantial range of inputs:

- ~1,000 story files × 8 story transforms, 5,397 save-story cases
- ~15,000 automigration runs, ~21,600 `ConfigFile` manipulations
- A 76-file adversarial corpus

The read path passed nearly all checks. Index entries were identical, and the Vite csf-plugin source maps were actually better than `next` (0 mis-mapped tokens, versus 147,000 on `next`). The problem was the write side. **Some changes could alter the meaning of user code without any error.**

From that point on, I didn't stop at checking whether the tests passed; I started by asking why the results should be the same. A parse error is easy to spot, but when a string value or a config object changes silently, it's hard to notice until a real user runs into it. The three cases below show that difference well.

### Case 1: A template literal's value changes

`reindent` added indentation to every line of the code being inserted. Lines inside template literals were no exception.

```ts
// A value inserted into an object via set(['code'], ...)
{ code: `line1
line2` }

// When read back
// Babel: "line1\nline2"
// OXC:   "line1\n    line2"  // the indentation ends up in the string
```

Whitespace inside a template literal is part of the string's value. Adding indentation line by line had no way of telling the difference.

For the fix, I parse the code to be inserted first, to find regions where line breaks affect the value, such as template literals and strings. `reindent` now leaves whitespace inside those regions alone. It went from adjusting indentation by looking only at lines of text to checking the code's syntax.

The same problem existed in three other helpers that re-indented code line by line (Vitest config wrapping, React source output, and others). In the end, I consolidated them all onto this single `reindent`.

### Case 2: A single parenthesis changes an array

With its default setting (`preserveParens: false`), OXC doesn't include outer parentheses in a node's span. But the editor used node spans as its boundaries.

```ts
// Original
export default { addons: [(cond ? 'a' : 'c')] };

// After running appendValueToArray(['addons'], 'b')
// Babel: [(cond ? 'a' : 'c'), 'b']
// OXC:   [(cond ? 'a' : 'c', 'b')]
// Because of the comma operator, this effectively becomes ['b']
```

It's not a syntax error, and nothing throws. The config file just quietly takes on a different meaning. I changed list edits to use the node's outer span, parentheses included (`outerSpan`), and this exact input became a regression test in `editor.test.ts`.

### Case 3: Who owns a comment?

The `csf-2-to-3` codemod converts the CSF 2 `Template.bind({})` pattern into CSF 3 objects and deletes the `Template` declaration. The problem was the comment above it.

```js
/** Shared template */
const Template = (args) => <Button {...args} />;
export const Primary = Template.bind({});
Primary.args = { label: 'A' };
```

```js
// Incorrect output: Template's comment is left on Primary
/** Shared template */
export const Primary = {
  args: { label: 'A' },
};
```

`Template`'s JSDoc was left behind and ended up attached to `Primary`. In Docs, `Primary`'s description becomes "Shared template." Had it been `// @ts-expect-error` or `// eslint-disable-next-line`, it would apply to the next export. In fact, the repository's `Zoom.stories.tsx` picked up five TS2578 errors after migration.

In an AST, comments aren't nodes; they're text between nodes. Babel/Recast attached comments to nodes, so deleting a node removed its comments too. With span editing, you have to decide for yourself "which statement does this comment belong to?" I changed it so that deleting a statement also deletes the comments attached in front of it, while deleting the first statement keeps the file header (license and so on).

### Gaps in the scope analysis

In section 3, I wrote that 99,828 bindings matched, but review still found gaps. Differential comparison can't catch patterns that don't exist in the repository's code.

```ts
let token;
export function setSession(s) {
  token = s.token;
}
export const getData = createServerFn().handler(async () => token);

// Babel: keeps the let token declaration
// Initial OXC implementation: deletes the declaration -> ReferenceError in setSession()
```

There were other issues too, such as parameter default values being resolved to declarations in the function body, and parameter decorators not being visited.

The more dangerous part was how unknown TypeScript nodes were handled. The original `scope.ts` treated any TS node it didn't recognize as type-only. If an OXC upgrade renamed a node or introduced a new one, references would silently be undercounted, and for dead-code elimination that's the most dangerous direction to be wrong in, because it can delete a live import. So I made it fail closed.

Now it throws when it hits an unknown node. Stopping was safer than deleting a declaration that's actually in use based on a wrong judgment.

### Other fixes

The review contained many more items besides these:

- The Webpack/Rspack loader discarded incoming source maps -> story files in every webpack project mapped to the wrong lines
- Parsing without a filename assumed TSX -> the generic arrow function `<T>(x: T) => x` in `.ts` files failed to parse, and Vite automock mocked nothing
- OXC doesn't report semantic errors by default -> files were indexed even when `export const A` was declared twice (fixed with `showSemanticErrors: true`)
- Code inserted into CRLF files always used `\n` -> mixed line endings
- Dropping Flow support needed to be an explicit breaking change -> `MIGRATION.md` and a dedicated error message for `// @flow` files

## 6. Fixes and verification after the review

I started by acknowledging the mistake. My [first reply to the review](https://github.com/storybookjs/storybook/pull/36613#issuecomment-6205481764) began like this:

> My earlier reply about indentation was too confident: `reindent` did shift lines inside template literals, as you found.

I shared the order I'd tackle the fixes in: parser, editor, source maps, per-package regressions, scope analysis, then migration docs. If the input is being parsed wrong, there's little point fixing the editor first; you can't trust the results.

I split the fix commits by review comment wherever possible, and left the corresponding commit hash on every inline thread. In the [first round](https://github.com/storybookjs/storybook/pull/36613#issuecomment-6206900511), 23 fix commits went on top of the original 17. For items without an inline thread, I listed the commits item by item in a PR comment. The aim was to let reviewers see at a glance which commit addressed which comment.

### Testing for equivalence without Babel

The review also changed how I tested. At first I thought, "Why not keep the Babel implementation around for tests and keep comparing against it?" Kasper suggested something different.

I pinned 27 inputs taken from the repository and saved the output of the last Babel `next` as `golden.json`. It records the values real callers use: index entries, `_stories` and `_meta`, test names and IDs, and scope bindings. For comparison I used `toEqual` rather than snapshots, which are easy to update, to keep anyone from accidentally updating a snapshot and shifting the baseline along with it.

```mermaid
flowchart TD
  In[27 pinned inputs] --> B[Last Babel<br/>implementation]
  In --> O[New OXC<br/>implementation]
  B --> C{Same output?}
  O --> C
  C -- Yes --> G[(Record in<br/>golden.json)]
  C -- No --> D{Intended change?}
  D -- Yes --> E[Record as a separate<br/>expected value] --> G
  D -- No --> R["Treat as regression,<br/>fix, then<br/>compare again"]
  G --> F["After Babel is gone,<br/>compare with toEqual<br/>on every OXC<br/>upgrade"]
```

These inputs and their expected values live together in `csf-tools/__corpus__/`. Twenty-six match Babel exactly, and the remaining one (a generic arrow function in a `.ts` file) has its own expected value, since what used to be a Babel parse error is now indexed.

Now, even after the Babel implementation is deleted, OXC upgrades can be checked against the same inputs and expected values. Intended differences are recorded separately in the golden file, and anything else is treated as a regression.

### Working together on the same branch

Near the end of the third round, Kasper [asked](https://github.com/storybookjs/storybook/pull/36613#issuecomment-6216796158) whether it would be OK to push directly to my branch, since the remaining follow-ups would go faster as commits than as back-and-forth in review threads. In return, Kasper promised not to force-push and asked me not to rebase.

I [accepted right away](https://github.com/storybookjs/storybook/pull/36613#issuecomment-6223999669). As promised, from then on I kept up with `next` only through merge commits instead of rebasing, and [said so explicitly](https://github.com/storybookjs/storybook/pull/36613#issuecomment-6229678588). With two people working on the same branch, agreements about how to handle history mattered as much as the code. After that, wrapping up review comments, restructuring the editor, and performance optimization all continued on the same branch. On supported environments (64-bit Node 22 or later, excluding Windows), we also used OXC's raw transfer to reduce AST serialization cost, and compared the output before and after the change.

On October 20, the [approving review](https://github.com/storybookjs/storybook/pull/36613#pullrequestreview-5585905815) arrived.

> Amazing work, let's go!!

## 7. Changes that affected downstream packages

The change didn't stop at `csf-tools`. Docs, Vitest, CLI automigrations and codemods, docgen for React, Vue, Angular, and Web Components, TanStack's server-code removal, mock extraction in both builders, and the sandbox scripts: every place that used Babel was migrated. Babel remains only where user code is compiled (`@storybook/nextjs`, ember).

The public API changed too. `CsfFile`, `CsfObject`, and `CsfEnricher` now expose ESTree nodes instead of Babel nodes, and `storybook/internal/babel` is gone. Some changes are visible to users as well:

- `docs.source.originalSource` is now the original text as written, not code regenerated by Babel
- In automigration output, code that wasn't touched keeps its formatting
- Error messages use the `message (line:column)` format instead of Babel code frames
- Flow story files are no longer supported

Since we decided not to add a compatibility layer, I went directly to the projects affected. I opened PRs against 10 community packages that read `storybook/internal/babel` or `CsfFile._ast` (storybook-rsbuild, msw-storybook-addon, solidjs storybook, react-spectrum, and others), moving them to the public `csf-tools` API. Until those packages ship releases, the corresponding sandboxes are temporarily excluded from CI, with comments in `sandbox-templates.ts` linking the PRs and stating the conditions for re-enabling them.

It was an internal API, but external packages were actually using it. Merging the core change alone wasn't going to finish the job.

## 8. One-line fix, separate PR

Along the way, I also fixed a bug unrelated to OXC. The Open Service runtime creates a timer to wait for a reply after sending a sync request, and in Node.js this timer held the event loop open, so one-shot commands that had finished their work wouldn't exit until the timer ran out.

```ts
silenceTimer.unref?.();
```

With `unref()`, the timer keeps working as normal while other work keeps the process alive, but it no longer prevents exit when it's the only thing left. It's the same pattern the repository's `file-lock.ts` used for its heartbeat.

It was originally part of the main PR, but Kasper's review pointed out that ["it's an unrelated commit, so it could go in separately"](https://github.com/storybookjs/storybook/pull/36613#discussion_r4281961830), so I split it into [PR #36861](https://github.com/storybookjs/storybook/pull/36861). Buried among more than 100 commits, the one-line fix was merged within a day, along with a single test, once it stood on its own.

## 9. Final measurements

Setup: the repository's 740 story files (1.45MB) and 80 config files, M1 Pro, median of 45 runs per task.

| Task | Babel | OXC | Speedup |
|---|---:|---:|---:|
| CSF indexing | 1,086ms | 128ms | 8.5x |
| Docs source processing (`enrichCsf` + source maps) | 1,183ms | 172ms | 6.9x |
| Vitest transform | 1,192ms | 288ms | 4.1x |
| 10 config edits | 138ms | 90ms | 1.5x |

Project-level numbers (for the internal Storybook UI):

| Metric | Before | After |
|---|---:|---:|
| `storybook index` | 1.65s | 0.84s |
| Dev server smoke test (median) | 4.76s | 3.87s |
| Full build | ~38.9s | ~36.0s |
| npm package, unpacked | 17.75MiB | 15.50MiB (-13%) |
| npm package, packed | 5.29MiB | 4.89MiB (-8%) |

An 8.5x microbenchmark doesn't make the full build 8.5x faster; a build does a lot more than parse CSF. Even so, indexing time was cut in half. Part of that came not from parser speed but from no longer loading Babel at all. Babel takes about 64ms to load per process or worker, while `oxc-parser` takes about 5ms.

On correctness:

- Zero differences from Babel's output across all three tasks: indexing, `enrichCsf`, and `vitestTransform`
- Internal UI `index.json`: 1,766 entries, byte-for-byte identical
- Scope analysis: 99,828 bindings matched
- 48 projects compile and pass `check`; 14,643 unit tests pass (the 4 local failures are environment-dependent issues in files that weren't changed)

## 10. Looking back

Initially, I thought eliminating duplicate parsing would be enough. Then it seemed like adding one more fast indexing path with OXC would do. What actually got merged wasn't a second indexer, but a change that replaced a CSF processing layer that had depended on Babel and Recast.

The problems I wrestled with longest weren't about parser speed either. They were questions like how much of a deleted declaration's comments to remove, whether parentheses belong in a span, and how to preserve whitespace inside template literals. The old implementation handled these without anyone spelling them out. Only after moving to a new implementation did I realize they were, in effect, compatibility requirements.

The first editor I wrote passed the existing tests, but it wasn't safe enough. Without Kasper's differential review, some of those regressions would have shipped. From then on, I fed the same inputs into both implementations, compared the results, and turned every problem I found into a regression test. And so that comparisons remain possible after Babel is removed, I kept the last Babel output as a pinned golden file.

Sidnioulz and Michael Shilman made me rethink the goal before I started implementing, and Kasper didn't stop at finding the gaps in the implementation; by the end, Kasper was pushing fixes to the same branch. This was not a case of code I prepared on my own getting merged as is.

In the end, CSF indexing became 8.5x faster, and real indexing time dropped by about half. But if I did this again, the first thing I'd prepare, before any performance measurement, would be a plan for **which inputs to compare the existing behavior against, and how far to take that comparison**. As the small initial optimization grew into a large replacement, that was where I spent the most time.

### Related PRs and Discussion

- [Discussion #36467 - [RFC] OXC fast path for CSF indexing](https://github.com/storybookjs/storybook/discussions/36467)
- [PR #36468 - Addon-docs: Skip re-parsing untransformed stories in the CSF plugin](https://github.com/storybookjs/storybook/pull/36468)
- [PR #36613 - CSF: Run csf-tools on OXC instead of Babel](https://github.com/storybookjs/storybook/pull/36613)
- [PR #36861 - Core: Let one-shot commands exit while open-service waits for sync replies](https://github.com/storybookjs/storybook/pull/36861)
