Skip to content

Add weft_css: styles in ReScript, extracted at build time, sent by what each page renders - #15

Merged
e-cline merged 16 commits into
mainfrom
claude/weft-css-support-c3db73
Oct 25, 2026
Merged

e-cline merged 16 commits into
mainfrom
claude/weft-css-support-c3db73

Conversation

@e-cline

@e-cline e-cline commented Oct 25, 2026

Copy link
Copy Markdown
Owner

What and why

CSS for Weft, including server components and server-rendered HTML. Styles are declared in ReScript beside their components (or as shared themes), extracted to stylesheets at build time, and each page gets only the stylesheets of what it actually rendered, with nothing ever shown before its CSS: not the first page, not a streamed segment, not a navigation or a refetch.

The plan, with the survey of React/RSC, the RSC Vite plugin, Next.js, TanStack Start and others, the decisions, and "As built" notes for each phase, is in docs/weft_css_plan.md. None of the existing systems fit as-is: rescript-css generates CSS at run time, and the RSC approaches send CSS by module, not by what rendered.

packages/weft_css (new)

  • Css.class, Css.var, Css.keyframes, Css.global at a module's top level, over a typed record of properties (keywords as @unboxed variants, Raw for anything else), with nested blocks, selectors and media. Css.vars sets variables at run time; Css.cx joins classes.
  • Cascade layers: classes in weft.components, globals and variables in weft.base, declared first in every sheet.
  • Sheets: each stylesheet loaded once per document, its state shared with the page's inline runtime.

ppx_weft

Names each style from its file, modules and binding, plus the file's sheet id (Card-Styles-card_w1x2y3z), and refuses styles anywhere but a module's top level, with errors saying what to do instead.

weft_vite_plugin

  • Extracts each style file's sheet by running it in a module runner of its own; strips the definitions from every bundle (only the names remain); serves each sheet as a CSS module.
  • weft() puts the sheets, and each entrypoint's, in virtual:weft/assets. weftCss() is the plugin for apps without a server.
  • Development: an edit to a style swaps the stylesheet in place, with no reload or refetch; an edit to a file a style file uses re-extracts it; a failed extraction is retried.
  • Harness: head stylesheets applied, others loaded a task later or held (holdStylesheets), and unstyled lists anything shown without its CSS.

weft_server and weft_dom

  • The payload gains C rows: each row says which stylesheets it needs, and the decoder waits for them before its content shows (a navigation keeps the old page meanwhile).
  • The first page holds its head open until the shell is ready, then links exactly the shell's stylesheets; later ones are preloaded as soon as the server knows them, and each streamed segment's swap waits for its own ($weftSwap(n, [hrefs])), including a segment nested in one still waiting.
  • dataId writes data-id.

Examples

  • weft_shop and weft_notes are styled with weft_css only: no other class names, and no inline styles but CSS variables. Tests find elements by data-id, role or ARIA attribute; an id is kept only where the page needs it.
  • weft_app, a client-only app, uses it through weftCss().

Testing

  • Unit and integration tests for every layer, each bug fix with a test checked to fail without it.
  • Tier 2 (jsdom, through the harness): pages stream and navigate with no element shown before its sheet, in weft_shop and weft_notes.
  • Tier 3 (Playwright): weft_shop's flash test watches every frame with every stylesheet slowed down, through streaming, navigation, the cart and a refetch, with a control that proves it catches a flash. It runs in Chromium, Firefox and WebKit, locally and in CI (which now installs all three).

Follow-ups (in the plan)

RPC responses don't send stylesheets yet; modules loaded by a dynamic import() don't bring theirs; small sheets could be inlined and sheets that always load together grouped.

Checklist

  • pnpm run check, pnpm run build and pnpm run test pass
  • Tests cover the change
  • A changeset is included (pnpm changeset) if a published package changed

🤖 Generated with Claude Code

e-cline and others added 16 commits October 24, 2026 23:39
docs/weft_css_plan.md: weft_css, styles in ReScript extracted at build
time, sent by what rendered (server) and what's loaded (client), with no
flash of unstyled content in SSR, streaming and navigation. Phase 0's
spikes (ReScript, the PPX, a CSS module runner, Vite's CSS pipeline, and
Chromium, Firefox and WebKit) corrected four details, and found WebKit
runs an async module script only when the parser gets more bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
packages/weft_css (WeftCss): the Css API, modelled on rescript-css's
(class, style, global, var, keyframes, media/supports/container, vars,
cx), a typed record of optional properties with Raw everywhere, keywords
as @unboxed variants whose run-time value is the keyword; Serialize (CSS
text, nesting as rescript-css does); Extract (registrations recorded
while files' top levels run, grouped into a sheet per file by the sheet
id their names end in, in cascade layers); SheetId (the file id's hash,
and parsing it back out of class lists and CSS).

ppx_weft rewrites Css.class/var/keyframes/global at a module's top level
into Css.Internal calls with a name (module path, binding, sheet id) and
the definition in a function, and gives the file 'use weft css'; style
calls anywhere else are compile errors. Fixtures pin the names, the
directives beside the others, the hash against SheetId's over UTF-8, and
the CSS extracted from compiled output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…_app

The plugin finds files marked 'use weft css' (the app's and its ReScript
dependencies'), checks their styles' names end in their sheet id, and
extracts one sheet per file by importing them in a module runner of its
own (a Vite server with a weft_css environment), through that runner's
copy of WeftCss.Extract: recording is now per copy of weft_css, so a dev
server rendering a request meanwhile isn't recorded. Builds extract at
buildStart; dev servers on the first sheet request, and a file again when
ReScript rewrites it.

Every environment's modules have their style calls replaced in place by
their names (globals by void 0), so neither definitions nor weft_css's
value layer is bundled. Sheets are CSS modules, virtual:weft/css/<id>.css,
resolved to one path under node_modules/.weft-css for Vite's CSS pipeline.
weftCss(), for apps without a server, has each style file import its
sheet; weft() includes the rest (delivering sheets to pages is phase 3).

A styled fixture tests weftCss() builds and dev; basic and widgets gain
styles; weft_app's inline <style> moves to src/Styles.res.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ll's in its head

Render.toStream(~styles) collects, per row, the sheets of the classes
the row renders (host classNames, strings in entrypoint props, an
entrypoint's own code's sheets, and the sheets those depend on, before
them), writes a C row for each the first time, and wraps the row's value
as ["$C", [rows], value]. The decoder settles C rows and unwraps the
wrapper; loading and waiting is phase 4's.

Page holds the head's end until the shell is ready, then links the
sheets the rows so far need there (with the entrypoints' modulepreloads),
so the shell never paints without its CSS. Payload responses get C rows.

The plugin's assets gain each sheet's URL and dependencies, and each
entrypoint file's sheets, from the compiled files' static imports; a
build emits every sheet from a chunk of its own. Every sheet now starts
with the layer order, so whichever loads first, weft.base comes first.

Moves Render's scan from phase 4 to here: the head needs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… them

WeftCss.Sheets loads a stylesheet once per document, as a link in the
head, adopting one the page already has (the shell's), knowing one that
loaded before anyone listened (its sheet is set), and counting one that
failed as loaded. Its states are on globalThis.__weftCss, for the page's
inline runtime to share; without a document every sheet has loaded.

The decoder takes ~loadSheet (the document's by default), loads a C row's
sheet as it arrives, and holds a J row back until the sheets it names
have loaded, as it does for its entrypoints. A loaded sheet costs no
microtask, so a payload that has fully arrived still never suspends.
Navigation and refetches render in a transition, so the old page (or a
boundary's fallback) stays until the new content's CSS is in.

The harness fires load for links jsdom adds, and records them
(WeftTesting.stylesheets). Checked in Chromium, Firefox and WebKit on a
temporarily styled weft_split, with CSS slowed: no unstyled frame on
navigation; with the decoder made not to wait, every frame was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WeftDom.Html.render(~stylesheetsOf) gives each segment's markup to Page,
which finds the sheets its class names are in (and their dependencies),
so the swap becomes $weftSwap(n, [hrefs]) and waits for them. The
runtime's $weftCss(href) keeps each sheet's state on self.__weftCss, the
shape WeftCss.Sheets keeps, from the page's link or a new one: one link
per sheet, whichever asks first. C rows after the shell are preceded by a
style preload. The server's decoder never waits for a sheet.

The harness applies the head's stylesheet links as loaded, loads the
others a task after they're added unless held (holdStylesheets,
releaseStylesheets), and reports elements shown without their CSS
(unstyled).

Checked in Chromium, Firefox and WebKit on a temporarily slowed,
styled boundary in weft_split: never shown unstyled, and hydrated; a
swap rewritten not to wait flashed for 91 frames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CSS plugin remembers each style file's code without its definitions;
a change after which that's the same, and only the sheet's CSS differs,
is CSS-only: the sheet is extracted again, its module invalidated, and
every environment's hotUpdate returns [] (so weft()'s refresh and Vite's
reload don't happen). weftCss() hands Vite the sheet's CSS module, for
Vite's own CSS update; weft() sends weft:css, on which the client puts a
fresh link after the page's and removes the old once the new has loaded.

Each file's change is decided once and kept by file: ReScript's lib/
copy of a compiled file changes at the same moment, and with one slot the
server environments recomputed and reloaded (found in a live check, now
in the dev test).

Checked live on weft_split's dev server: editing a style's colour
changed it on the page, keeping state, with no reload anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… content

weft_shop's hundred-line stylesheet, inlined in every page's head, is gone:
the theme's variables and the page's rules are in src/shared/Theme.res,
and each component's rules beside it (the server's frame, blocks and pages
as server-only sheets; the entrypoints' with their code). The semantic
class names stay beside the styles', for the tests and the HTML.
weft_css gains clip.

Tier 2 (tests/Styles_test.res): the home page's head links only what it
renders, the cart's sheet comes with the cart, nothing is unstyled as the
page streams gate by gate, and a held sheet keeps the old page on screen.
The harness starts each page with no stylesheet loaded, and counts a swap
that runs late (after its sheets) as the HTML's.

Tier 3 (e2e/styles.e2e.ts): with every stylesheet slowed, a probe on
every frame finds nothing shown before its CSS, from the home page
streaming through a purchase and a refetch; a control shows it would.
Passes in Chromium, and once in Firefox and WebKit. The Playwright
config declares Node's types, so it type-checks by itself (as the commit
hook checks it).

READMEs and the plan updated: every phase done, with follow-ups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A boundary inside one waiting for its sheets: its swap (or failure) ran while
  its placeholder was still in the outer one's template, and was dropped. It now
  waits in `$weftLate`, run again after the next swap.
- A head link that had failed already was waited for forever. The head's sheet
  links set `weftSettled` when they load or fail, which `$weftCss` and
  `WeftCss.Sheets` read.
- In development, a style file's definitions using another file's values went
  stale when that file changed: a change now drops the runner's copies of the
  file and its importers, and extracts the style files among them again, sending
  `weft:css` for each sheet that changed.
- In development, a failed first extraction was kept until a restart; it's now
  tried again.

Tests for each fail without its fix. Also: a page sent as rows only, with
sheets; the harness reads the head's new links; and weft_shop's flash test runs
in Firefox and WebKit as well, locally and in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
weft_notes had no styles: it now has a theme (colours as variables, the page's
rules, shared looks) and each component's styles beside it, with tests that each
page's head links only what it renders, a note's sheets load when it's opened,
and nothing shows unstyled as a note streams in.

weft_shop's last inline styles (a product's picture gradient, the carousel's
offset) are weft_css classes now, driven by variables set with `Css.vars`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The semantic class names kept beside the styles' (`"card"`, `"hint"`,
`"carousel-controls"`, ...) are gone. The tests found elements by some of them;
they now use what the page already has: ids (`#total-<sku>` and `#left-<id>`
added), roles and ARIA attributes (`[role="listbox"]`, `[aria-busy="true"]`, on
the loading placeholders too), or elements in one with an id (`#signin-page
form`, `#card-p1 h3`). A price is a `<data>` element, its value in cents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An element the tests find has a `data-id` (the new `dataId` prop on host
elements), not an `id`. Ids stay where the page needs them: a label's `for`, an
ARIA reference (the comment's hint, a tab and its panel, the search's list), the
checkout's fields. `Link` and `Price` take `dataId` instead of `id`. The
harness names an element without an id by its `data-id` (`tag[data-id=name]`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and the plugin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it has a sheet

In WebKit on Linux (CI), the control, a page shown before its stylesheet, found
nothing: a link's `sheet` there can be set while it's still loading, so the
probe took every sheet as applied, and the flash test could pass whatever was
shown. The probe now watches each stylesheet link from when it's added, and
counts it applied once it fires `load` (or `error`). It also counts its frames,
and both tests check it ran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… runs frames

On CI, headless WebKit on Linux ran the probe's animation frame once in the
whole control test, so the probe hardly looked at all (the frame count added in
the last commit showed it; the `sheet` explanation there was a guess). From the
first frame, once the head's render-blocking stylesheets have loaded, the probe
now also looks after every task: what's in the document then is what the next
frame would show. Both tests check it looked, and say how often, with the frame
count and the page's visibility, if it didn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its link settles

With the probe looking after every task, WebKit on Linux showed sale prices
"unstyled" for 1 ms: their sheet was set (parsed and applied, which is when
Weft's runtime counts it loaded) and its `load` event, a task later, not yet
fired. A sheet is applied once it's set, as before 35f7e11, or once its link
fires `load` or `error`. The control still fails the run if an engine sets
`sheet` before the sheet has loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@e-cline
e-cline merged commit c73c884 into main Oct 25, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant