Migrating to v3
Move a zfb 2.x project to the owned styling and component engines.
Before you start
This guide is for projects using Tailwind CSS, Preact or React components, islands, MDX, or the old zfb configuration. Version 3 removes those engine integrations. There is no compatibility mode or automatic conversion. Commit a working 2.x baseline and migrate your project and any configuration preset together. Upgrade a preset package first: a preset that still supplies a removed key fails configuration loading even after the app removes its copy.
Use a v3 zfb binary and matching @takazudo/zfb packages for the checks below. A binary or types installed in a 2.x docs host still describe 2.x behavior.
Configuration
Delete framework, including framework: "preact" or framework: "react". The remaining key produces this configuration error:
the framework key was removed in zfb 3; delete the key. zfb now uses zudo-react.Replace tailwind with wind. To keep authored CSS and CSS Modules without generated utilities, use wind: false instead of tailwind: { enabled: false }. A leftover key produces:
the tailwind key was removed in zfb 3; utilities are compiled by the built-in zudo-wind engine. Replace tailwind: { enabled: false } with wind: false, or delete the key. See the v3 migration guideAn absent wind key enables an empty v1 configuration with reset none and no tokens; it does not restore the old theme. Nested wind objects reject unknown fields. The wind configuration reference lists every field and merge rule.
Stylesheets
Remove @import "tailwindcss", its preflight and utilities imports, and Tailwind directives such as @theme, @source, @apply, @utility, and @plugin from the entry sheet and imported sheets. Remaining directives fail with diagnostic ZW009, including under wind: false; one run lists every remaining directive with its file:line:column. Put custom selectors and declarations in authored CSS; put utility values in wind.tokens.
Authored var() references to Tailwind's default theme variables lose their definitions too; see Default theme variables.
Version 3 supplies no implicit palette, spacing unit, named scales, or breakpoints. Select none, minimal-v1, or owned-v1 explicitly for wind.reset; none is the default. The new basic-blog scaffold chooses owned-v1 and defines its palette and spacingUnit, then restores prose list styling in authored CSS. Check headings, lists, borders, form controls, font stacks (html and code/pre), sub/sup, ::placeholder, ::file-selector-button, and [hidden] when choosing a reset. A pixel diff of a page without those elements cannot catch their differences. See Differences from Tailwind preflight.
import { defineConfig } from "zfb/config";
export default defineConfig({
wind: {
spec: 1,
reset: "owned-v1",
tokens: {
spacingUnit: "0.25rem",
colors: { accent: "var(--color-accent)" },
},
breakpoints: { sm: { minWidthPx: 640 } },
dark: { attribute: "data-theme", value: "dark" },
},
});Declare --color-accent in your stylesheet, and add tokens for every utility your project uses. A class-position name with no matching utility root, such as site-header, remains ordinary class text. A name that starts with a catalog root is validated using the longest matching root, so text-link receives ZW006 if its color/font-size token is missing. If CSS owns that complete candidate, reserve it with wind.authoredClasses: { "text-link": true }; an exact match suppresses utility validation, diagnostics, and generated CSS. Otherwise correct the utility spelling or declare its missing token. Utility language and reset choices differ from Tailwind. Use the Tailwind migration map for directives, variants, tokens, and unsupported constructs.
Authored names that use underscores, such as BEM card__title or snake_case card_title, are ordinary class text like card-title. A Tailwind utility that zudo-wind does not implement, such as line-clamp-2, contents, or container, produces a ZW014 warning naming the declaration to write in CSS instead; set wind.strict: true to fail the build on it. A class string kept in a const or a conditional branch is checked like a direct class once it reaches a class attribute, so an unsupported utility there also fails the build. See Diagnostics and tools.
Components and islands
zfb build and zfb dev already compile page, component, and island JSX with the automatic transform and jsxImportSource:. They do not need a project tsconfig.json, and ignore its jsx and jsxImportSource settings; a per-file @jsxImportSource pragma can override the build transform for that file. Set compilerOptions.jsx to react-jsx and compilerOptions.jsxImportSource to @takazudo/ in tsconfig.json so zfb check, editors, and TypeScript checks of test files use the same runtime's JSX types. Configure test-runner JSX transforms separately, unless the runner is set up to read these options from tsconfig. Keep @takazudo/zfb and @takazudo/zfb-runtime where the project uses them; remove direct preact, react, react-dom, @types/react, and @types/react-dom dependencies when they were present only for zfb's old engines. The runtime is shipped through @takazudo/, not a separate package.
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@takazudo/zfb/zudo-react"
}
}Search for leftover per-file / or / pragmas, for example with grep -rln "@jsxImportSource" src pages components, and remove them from components rendered by zudo-react. A per-file pragma is the only build-time override to the fixed import source, and choosing React or Preact there does not add support for those renderers. Without Preact installed, a Preact pragma fails the build with Could not resolve "preact/jsx-runtime"; with Preact still installed (for example as a widget peer), static rendering fails with a ZR_CHILD diagnostic. Before either failure, zfb build and zfb dev print a zfb warn: line naming the project file, line and column of each such pragma in a page or a module the page imports. The warning only reports; it does not change the exit status. Files in dependencies are not scanned, so a Preact widget package that is lazily mounted behind a DOM host, as in Embedding a third-party widget, builds without a warning. zfb check also reports authored .tsx and .jsx pragmas beyond pages, including with --skip-tsc; it excludes dependencies, generated output, and scratch files. Its ZB005 warnings do not fail the check.
Rewrite hook components as setup-once zudo-react components. Use signal() for writable state and form models, computed() for readonly derivations, getScope() and scope.effect() for lifecycle work, and Show or For for owned conditional and keyed regions. Readonly or computed signals cannot serve as writable models. JSX uses HTML and CSS spellings such as class, for, and style={{ "background-color": "red" }}, with native on:click listeners. Replace React-style props and unbranded element literals with zudo-react JSX or h(). Use rawHtml only for trusted content; it cannot have children. Text children on <script> and <style> are rejected: pass a trusted static string with <style rawHtml={CSS} /> or <script rawHtml={JS} />, or use a childless external <script src=. Those raw-text strings are emitted verbatim and are not escaped or sanitized. This specific rule does not apply to <title> or <textarea>, which keep their normal text-child behavior. The Preact hooks migration map gives the conversion patterns.
Standard HTML and SVG markup uses the shared finite vocabulary across TypeScript, server rendering, hydration, and fresh mounts. Write Open Graph metadata such as <meta property="og:title">, preload links such as <link rel="preload" as="font">, external <script defer> elements, and SVG use/href markup directly in JSX. These supported attributes do not require a rawHtml workaround. Unknown attribute names and React-style spellings remain errors; custom-element attributes remain string-only. See Components and JSX for value categories and SVG namespace behavior.
Check hand-authored raw HTML tables in .mdx and .md files during migration. A raw <table><tr>… fails with ZR_PARSER_CONTEXT: table > tr; add an explicit <tbody> around those rows. This rule applies to raw HTML in both formats. GFM pipe tables are separate: the Markdown compiler already emits <thead> and <tbody>, so they need no change. See table and select children for the full structure rules.
A build or dev render that rejects a React-style spelling names the supported spelling and, for JSX, the authored file, line, and column of the element. zfb check is not required first. See Read a render failure for how that location differs from the structural context and the generated bundle stack.
Linting zudo-react JSX
React ESLint rules can reject spellings that zudo-react requires. In eslint-plugin-react, react/no-unknown-property is part of the recommended config: it treats class, for, and crossorigin as React-style errors and can autofix them to className, htmlFor, and crossOrigin. It also reports on:click as unknown. Its ignore option accepts exact names, so it cannot cover the open-ended on:<event>[:capture] listener form. See the no-unknown-property rule.
Turn off that rule only for JSX files zfb compiles with zudo-react. The automatic transform also has no React binding, so disable react/jsx-uses-react and react/react-in-jsx-scope on those same files. If your config enables React hook or Fast Refresh rules there, move those rules or presets to React-rendered files, or disable the relevant configured rules on the zudo-react files: react-hooks/rules-of-hooks, react-hooks/exhaustive-deps, and react-refresh/only-export-components. zudo-react components use signals and scopes instead of React hooks, and the React Refresh rule checks a Fast Refresh integration that zfb does not provide. Keep these plugins and their rules on files that still use React. Current React Hooks presets include additional React-specific rules; this list describes known migration conflicts and does not certify other rules for zudo-react. See the React Hooks lint rules and React Refresh rule.
In a mixed project using flat config, add an override after the React presets. Replace the sample glob with the exact files rendered by zudo-react. Add the hooks and refresh rule IDs above to a matching override only if those plugins are already configured; keep their presets scoped to React-rendered files. ESLint documents this file matching in its flat config guide.
{
files: ["components/zudo-react/**/*.{js,jsx,ts,tsx}"], // replace with your zudo-react source files
rules: {
"react/no-unknown-property": "off",
"react/jsx-uses-react": "off",
"react/react-in-jsx-scope": "off",
},
}Use TypeScript and the renderer to check the zudo-react contract: point tsconfig.json at the zudo-react JSX types as described above, then run zfb check; zfb build and zfb dev report renderer spelling errors with the supported name and source location. ESLint rule suggestions and autofixes do not change that contract.
<Island> remains the build-time wrapper for a component. Its transport is stricter: use one component child, JSON props, and an identity that agrees with the source scanner. Hand-authored wrappers need the complete valid metadata. Hydration adopts matching server markup or fails closed per island; skipped SSR roots use data-when scheduling and replace fallback after a successful client mount. Review form behavior because supported controls use explicit writable models, single-select only, and the DOM value wins during hydration. See the islands guide.
Optional island props
For optional island props, own enumerable string data properties on records whose value is undefined are omitted recursively before SSR and JSON transport, including properties in records inside arrays. The server component and the client receive the same normalized keys, so Object.hasOwn checks agree. An explicit null remains a present property:
This matches v2's omission of undefined-valued record members. Arrays stay strict: an undefined array entry or a hole is an error rather than a value to coerce. Use null when an array value should remain present.
import { serializeProps } from "@takazudo/zfb/zudo-react/server";
serializeProps({
currentSlug: undefined,
nodes: [{ slug: "a", description: undefined }, { slug: "b", description: null }],
});
// {"nodes":[{"slug":"a"},{"slug":"b","description":null}]}See Server rendering for the full transport contract.
The zudo-react client runtime has no promise to match Preact's bundle size, and no fixed byte budget has been selected. A separate follow-up will measure production island output, evaluate safe optimizations, and set any regression budget from those results. That work does not block the v3 migration. This guide makes no project-specific size claim.
Same-package duplicate island definitions
Version 3.2.0 removed an exemption that v2.7.1 added for island marker collisions (#2441). The v3.2.0 release notes did not originally announce this breaking change. Before 3.2.0, zfb silently kept one of two same-named island definitions when both files sat in the same node_modules package, or when one file was a byte-identical staged copy of a file that package ships. Island identity is now the defining module path and binding. Matching package names, bytes, or a compiled counterpart do not prove that two definitions are the same function.
The typical case is a package that ships both compiled dist/ output and its sources. One page imports the compiled export, while a generated route imports a copy of the package source staged outside node_modules. Both boundaries now resolve to different definitions with the same marker, and the build fails:
ambiguous owned island marker "Widget": <first definition> (Widget) used at <first page> and <second definition> (Widget); rename one defining functionMake every boundary import one definition:
Import the package's compiled export in both places.
Or make the staged copy re-export the component from the package, such as
export { Widget } from "@scope/, instead of duplicating its source.pkg/ routes/ index";
Rename one defining function only when the two definitions really are different components. An explicit trusted-provenance mapping that declares a staged copy equivalent to its package source is the designed future path, but it is not available yet.
Islands rendered outside a zfb build
Version 3.0.0 made Island validate its child, and the v3.0.0 release notes did not originally announce this change. In v2, Island took the marker name from displayName ?? name and emitted its wrapper without checks, so a minified function with a displayName still rendered. In v3, it reads the build identity and registered island names that zfb's server bundle sets, and it compares the child's function name with its displayName. Pages rendered by zfb are unaffected. A package or script that renders Island with another bundler or plain Node now fails with ZR_ISLAND_IDENTITY, for example:
ZR_ISLAND_IDENTITY: ThemeToggle has no build identity
ZR_ISLAND_IDENTITY: dc conflicts with displayName SidebarToggleWrap the synchronous render in withIslandTestContext from @takazudo/, and keep function names in the consumer bundler's minified output. See Rendering Islands outside a zfb build.
MDX and md-wasm
The @takazudo/zfb-md-wasm compile() and renderHtml() options no longer accept jsxRuntime. Remove the field rather than selecting react or preact; the typed API rejects it and the JSON validator reports an unknown field naming jsxRuntime. Compiled MDX imports its Fragment from @takazudo/. Ensure a consumer bundling compiled output resolves that subpath and does not alias it to an old engine.
CLI and environment
Remove ZFB_TAILWIND_BIN and ZFB_TAILWIND_OXIDE_WARMUP from scripts and CI. Tailwind's bundled binary and pin are gone. zfb css remains the standalone CSS command and now compiles with zudo-wind; its explicit --source inputs still work, including package files under node_modules or dist when they are outside the project’s configured output directory. Matches under the configured outDir, at the --output path, or under zfb scratch roots are excluded: if all matches are excluded, zfb css fails with ZW010; if only some are excluded, it warns with ZW010 and scans the rest. See the CLI source rules. zfb wind explain <candidate> explains one candidate, and zfb wind audit inspects project candidates. Build and dev scan .tsx, .ts, .jsx, .js, .mdx, and .md; standalone explicit sources can additionally use .html and .mjs.
zfb --version now reports the release version and embedded esbuild version, with no Tailwind line. Use the wind diagnostics guide to inspect migration failures.
Packages that ship classes or components
A package that previously relied on a shipped safelist should publish complete candidates in a versioned JSON manifest. The consumer declares the file under its producer key; distinct producers survive preset and app merging. Every manifest entry is validated strictly, and the consumer must configure its tokens. A manifest does not define tokens or discover itself automatically. Generate it with zfb wind manifest --producer <id> --output <file> --config <consumer config> instead of maintaining it by hand; see zfb wind manifest.
{
"schemaVersion": 1,
"specVersion": 1,
"producer": "widgets",
"candidates": ["group", "p-0", "hover:bg-accent"]
}import { defineConfig } from "zfb/config";
export default defineConfig({
wind: {
manifests: { widgets: { path: "widgets/wind.json" } },
tokens: { colors: { accent: "#2563eb" } },
},
});The widgets package must export .. A bare specifier resolves through its package exports; a . path resolves from the directory of the config that declares it (or from the declaring preset package). See manifest path resolution.
Ship package components using imports from @takazudo/ and the same JSX import source. Do not ship Preact or React JSX runtime imports in components rendered by zudo-react. An opaque self-mounting widget with its own framework can be lazily loaded from an island; see Embedding a third-party widget. See sources and candidates for manifest validation and root rules.
Checklist
Upgrade configuration presets, then install matching v3 packages and binary; run
zfb --versionand inspectpackage.json.Remove
frameworkand replacetailwindwithwindorwind: false; runzfb checkto catch removed keys and invalid nested data.Remove Tailwind imports and directives, set tokens and a reset, then check font stacks on
htmlandcode/pre,sub/sup,::placeholder,::file-selector-button, and[hidden]on representative pages. Runzfb css --input <entry.css> --output <output.css>using your project's entry and output paths.Set the tsconfig JSX options for
zfb check, editor, and test-file type checks; configure test-runner JSX transforms separately. Remove per-file@jsxImportSourcepragmas, update dependencies, components, islands, and form models, and scope React spelling, hooks, and refresh rules to the JSX files that still use React. Apply the zudo-react ESLint override only to zudo-react files, then runzfb checkand fix type errors.Remove
jsxRuntimefrom md-wasm calls and update compiled-output consumers; run your package's TypeScript check.Replace finite dynamic classes with complete literals or a manifest; use
zfb wind audit --fail-on erroras a CI or pre-push gate, and runzfb wind explain <candidate>on representative classes. For a large inventory,zfb wind audit --json --severity warningandzfb wind explain --stdin --jsongive machine-readable results in one process.Remove Tailwind environment overrides from build scripts; run
zfb buildand compare key pages, islands, controls, and styles in a browser.