Repository navigation
Add weft_css: styles in ReScript, extracted at build time, sent by what each page renders - #15
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-cssgenerates 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.globalat a module's top level, over a typed record of properties (keywords as@unboxedvariants,Rawfor anything else), with nested blocks,selectorsandmedia.Css.varssets variables at run time;Css.cxjoins classes.weft.components, globals and variables inweft.base, declared first in every sheet.Sheets: each stylesheet loaded once per document, its state shared with the page's inline runtime.ppx_weftNames 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_pluginweft()puts the sheets, and each entrypoint's, invirtual:weft/assets.weftCss()is the plugin for apps without a server.holdStylesheets), andunstyledlists anything shown without its CSS.weft_serverandweft_domCrows: each row says which stylesheets it needs, and the decoder waits for them before its content shows (a navigation keeps the old page meanwhile).$weftSwap(n, [hrefs])), including a segment nested in one still waiting.dataIdwritesdata-id.Examples
weft_shopandweft_notesare styled withweft_cssonly: no other class names, and no inline styles but CSS variables. Tests find elements bydata-id, role or ARIA attribute; anidis kept only where the page needs it.weft_app, a client-only app, uses it throughweftCss().Testing
weft_shopandweft_notes.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 buildandpnpm run testpasspnpm changeset) if a published package changed🤖 Generated with Claude Code