diff --git a/.changeset/vite-plugin-weft-css.md b/.changeset/vite-plugin-weft-css.md new file mode 100644 index 0000000..3dbf2bc --- /dev/null +++ b/.changeset/vite-plugin-weft-css.md @@ -0,0 +1,11 @@ +--- +'@e-cline/weft_vite_plugin': patch +--- + +`weft_css` styles: the plugin extracts each style file's stylesheet (running it in a module runner +of its own), strips the definitions from every bundle, and serves each sheet as a CSS module. +`weft()` puts the sheets in `virtual:weft/assets` for `weft_server`; `weftCss()` is the plugin for +an app without a server, whose modules import their sheets. In development an edit to a style +changes the stylesheet in place, with no reload. The test harness applies the head's stylesheets, +loads the others a task later (`holdStylesheets`, `releaseStylesheets`), reports what shows without +its CSS (`unstyled`), and names elements by `data-id` when they have no id. diff --git a/.changeset/weft-css.md b/.changeset/weft-css.md new file mode 100644 index 0000000..e5d17c0 --- /dev/null +++ b/.changeset/weft-css.md @@ -0,0 +1,12 @@ +--- +'@e-cline/weft_css': patch +--- + +A new package: styles declared in ReScript, extracted to CSS at build time. `Css.class`, `Css.var`, +`Css.keyframes` and `Css.global`, at a module's top level, take a typed record of properties (with +nested blocks, `selectors` and `media`); `ppx_weft` names each from its module and binding, and +`weft_vite_plugin` extracts each file's styles into a stylesheet of its own, leaving only the names +in the JavaScript. Classes are in the cascade layer `weft.components`, globals and variables in +`weft.base`. `Css.vars` sets variables in a `style` prop, for what changes at run time; `Css.cx` +joins class names. `Sheets.load` and `Sheets.isLoaded` load a stylesheet once per document, sharing +their state with the page's inline runtime. diff --git a/.changeset/weft-dom-stylesheets.md b/.changeset/weft-dom-stylesheets.md new file mode 100644 index 0000000..7b26a93 --- /dev/null +++ b/.changeset/weft-dom-stylesheets.md @@ -0,0 +1,7 @@ +--- +'@e-cline/weft_dom': patch +--- + +`Html.render ~stylesheetsOf`: a streamed segment's swap waits for the stylesheets its markup needs. +A segment inside one still waiting runs once that one is in. A new `dataId` prop writes `data-id`, +for tests to find an element by. diff --git a/.changeset/weft-server-stylesheets.md b/.changeset/weft-server-stylesheets.md new file mode 100644 index 0000000..bb4c860 --- /dev/null +++ b/.changeset/weft-server-stylesheets.md @@ -0,0 +1,11 @@ +--- +'@e-cline/weft_server': patch +--- + +Stylesheets: given the app's (`Page.assets`' `sheets` and `entrypointSheets`, from +`virtual:weft/assets`), `Render.toStream ~styles` sends each row the stylesheets its class names +need (`C` rows, before the row), and the decoder waits for them before a row's content shows +(`Decoder.make ~loadSheet`). A first page links the shell's stylesheets at the end of its head, held +until the shell is ready, preloads the ones the rest of the page needs, and each streamed segment +waits for its own before it's swapped in. Navigations and refetches keep the old content until the +new one's stylesheets have loaded. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47e6515..9c8260b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: e2e: # The browser tests (examples/weft_notes/e2e and examples/weft_shop/e2e), against the built apps - # in Chromium, on one Node version. Everything a browser isn't needed for is tested in the job + # in Chromium (and weft_shop's styles test in Firefox and WebKit too), on one Node version. Everything a browser isn't needed for is tested in the job # above. name: Browser tests runs-on: ubuntu-latest @@ -76,9 +76,9 @@ jobs: - run: vp pack working-directory: tools/weft_vite_plugin - run: vp run build - - name: Install Chromium for Playwright + - name: Install Chromium, Firefox and WebKit for Playwright run: pnpm --filter @e-cline/weft_notes_example exec playwright install - --with-deps chromium + --with-deps chromium firefox webkit - run: vp run --filter @e-cline/weft_notes_example e2e - run: vp run --filter @e-cline/weft_shop_example e2e diff --git a/README.md b/README.md index 2040479..b998cc3 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ WARNING: None of this is intended for real production use. Weft is the UI framework in here: `weft` (components, hooks and a fiber reconciler), `weft_dom` (the DOM renderer) and `weft_signals` (hooks for reading `signals`), plus the `weft_app` example. Server components for it are under way: `weft_server` and `weft_vite_plugin`, with the `weft_notes`, -`weft_split` and `weft_shop` examples. +`weft_split` and `weft_shop` examples. So is CSS: `weft_css` ([its plan](docs/weft_css_plan.md)). ## Packages @@ -24,6 +24,7 @@ components for it are under way: `weft_server` and `weft_vite_plugin`, with the | [`@e-cline/weft`](packages/weft) | A React-like component library: `@weft.component` components and hooks, rendered by a fiber reconciler through a pluggable host renderer. | | [`@e-cline/weft_dom`](packages/weft_dom) | The DOM renderer for `weft`: render components into a browser page. | | [`@e-cline/weft_signals`](packages/weft_signals) | Hooks for reading `signals` atoms and computeds from `weft` components. | +| [`@e-cline/weft_css`](packages/weft_css) | Styles written in ReScript beside their components, extracted into CSS at build time, and each page sent only the CSS of what it renders. | | [`@e-cline/weft_server`](packages/weft_server) | Server components for Weft: render on the server into a streamed payload, decode it on the client, with entrypoints loaded on demand. | | [`@e-cline/weft_vite_plugin`](tools/weft_vite_plugin) | The Vite plugin for Weft's server components: server and client environments, an entrypoint per chunk, the boundary enforced, dev and build. | diff --git a/docs/weft_css_plan.md b/docs/weft_css_plan.md new file mode 100644 index 0000000..837f549 --- /dev/null +++ b/docs/weft_css_plan.md @@ -0,0 +1,927 @@ +# Plan: CSS for Weft + +Styles written in ReScript, next to the components that use them (or in modules of their own, for +shared ones), extracted into ordinary CSS files at build time, and sent to the browser only when +something on the page uses them, with no flash of unstyled content: for client components, for +server components, for server-rendered HTML streamed in pieces, and for pages and parts of pages +fetched on navigation. + +```rescript +// src/client/AddToCart.res: an entrypoint +@@jsxConfig({version: 4, module_: "WeftDom.Jsx"}) +open WeftCss + +module Styles = { + let button = Css.class({ + display: InlineFlex, + gap: Rem(0.5), + padding: Px(8), + color: Var(Theme.onAccent), + background: Var(Theme.accent), + borderRadius: Px(6), + hover: Css.style({background: Var(Theme.accentHover)}), + disabled: Css.style({opacity: 0.5}), + }) +} + +@weft.component({kind: Entrypoint}) +let make = (~sku: string) => + +``` + +```rescript +// src/server/ProductPage.res: a server component, whose styles never reach the client as code +@@jsxConfig({version: 4, module_: "WeftDom.Jsx"}) +open WeftCss + +let page = Css.class({display: Grid, gridTemplateColumns: Raw("2fr 1fr"), gap: Rem(2.0)}) +let price = Css.class({fontSize: Rem(1.5), fontVariantNumeric: TabularNums}) + +@weft.component({kind: Server}) +let make = async (~id) => { + let product = await Db.product(id) +
+ +

{WeftDom.Jsx.string(Money.format(product.price))}

+ +
+} +``` + +`ProductPage`'s CSS reaches the browser because the page rendered `page` and `price`, and only then; +`AddToCart`'s, because the browser loads `AddToCart`'s code. Neither is shown before its CSS has +loaded. + +## Status + +Every phase, 0 to 7, is done, on the branch `claude/weft-css-support-c3db73`, a commit each (one PR +for all of them). The decisions were taken as recommended (Decisions). The design sections are as +first written, each followed by "As built" notes where the implementation went its own way or +settled what the design left open: phase 0's spikes under "Phase 0: what the spikes found" (after +Phases), then phase 1's after §2, phase 2's after §3, phases 3 and 4's after §4, phase 5's after §6, +phase 6's after §7, and phase 7's after §8. What's left to do is under Follow-ups, at the end. + +The things most worth knowing, for whoever works on this next: + +- **Phase 3 took `Render`'s scan from phase 4**: the head can only link the shell's sheets if the + server knows them. An entrypoint's sheets go on the row that uses it, not its `E` row. +- **A segment's sheets are its markup's**, found by `Page` in the HTML, not "every sheet since the + shell" (phase 5). +- **The server's decoder never waits for a sheet**, and the harness starts every page with no + stylesheet loaded and counts a swap that happens late (after its sheets load) as the HTML's: each + found when a jsdom test put the server and the client in one process. +- **ReScript writes a copy of each compiled file in its `lib/`**, whose change arrives with the real + one's: anything the plugin decides per change has to be kept per file (phase 6). + +## What was checked before writing this + +Against the code as `main` has it (after `weft_shop`, PR 14), and the sources named: + +1. **Weft has no CSS story yet.** The plugin's `virtual:weft/assets` has `styles` (the stylesheets + the bootstrap's chunks import, from the client manifest), and `Page` writes them as + `` in the head; entrypoint files' CSS isn't carried at all (`buildAssets` + keeps only `closure(...).files` for them, not `css`). Both plans list CSS beyond what Vite does + as out of scope. `weft_shop` puts a 100-line stylesheet, as a string, in every page's head + (`examples/weft_shop/src/server/Styles.res`, through `Page.respond(~head)`). +2. **`rescript-css` (`@jvlk/rescript-css` 0.2.0) can't be used as it is**, though its API is a good + one to follow (read at its current `main`): + - Its Vite plugin **rewrites the compiled `.res.js` files on disk** (at `configResolved` and + again in `transform`): it replaces each style call with its class name and adds + `import "./Button.css"` at the top, writing the `.css` file beside it. Here the compiled output + is committed and a clean build has to reproduce it exactly (the server components plan's + "Things learned"), so a plugin that edits it is a non-starter. + - It finds what to replace with **regular expressions over the compiled JavaScript** + (`let Styles = {...}` patterns, `export {...}` lists), which is tied to the compiler's output + format; Weft's plugin reads only directives the PPX writes, for that reason. + - It collects CSS by **`import()`ing every compiled module of the app in Node**, with a collector + on `globalThis`; class names are `rc__`, numbered in the order the + module's calls run. The runtime and the extraction both depend on that global collector (nested + `hover: Css.style(...)` registers a rule in it too), so its value layer can't be reused without + its runtime. + - **Its CSS is attached to modules by side-effect imports**, which is what makes it work with + Vite's CSS code-splitting, and also what makes it not work for server components: a server + component's file is never in the client's module graph, so its stylesheet is never in the + client build, and nothing tells the page it's needed. + - It needs Node ≥ 24.12 (the workspace allows ≥ 22). +3. **React 19's stylesheet resources** (react.dev, ``): a `` with a + `precedence` is hoisted into the head, de-duplicated by `href`, ordered by precedence (groups in + the order first seen, later groups winning), and **the component rendering it suspends until it + has loaded**, so the nearest `Suspense` boundary keeps its fallback (or, in a transition, the old + content stays) until the CSS is there. In streaming SSR (from Fizz's source as I know it, not + re-read for this plan), Fizz holds a boundary's reveal until the stylesheets it rendered have + loaded (its completion script, `$RR`, inserts them and waits), rather than blocking the parser. + Server components reach it with `ReactDOM.preinit(href, {as: "style", precedence})`, which Flight + sends as a hint row. +4. **`@vitejs/plugin-rsc`** (its README): CSS imported by a server component's module, directly or + not, is collected with `import.meta.viteRsc.loadCss(importer?)`, a React node of + ``s; by default the plugin wraps every capitalized exported function of a server + module that imports CSS in it (`rscCssTransform`, with a `filter` to opt out). A client + reference's CSS comes with it (`onClientReference` sees `deps: {js, css}`). The unit is the + **module graph**: what's imported, not what's rendered. +5. **Next.js's App Router** (from its behaviour as I know it; its docs didn't say, and this is worth + confirming against its source if it matters) works the same way, per route segment: the CSS of a + layout's or page's module graph (server and client components) is listed in the RSC payload for + that segment and rendered as React stylesheet resources (`precedence="next"`), so a client + navigation's new segments suspend until their CSS has loaded. **TanStack Start** (its CSS guide) + puts the CSS of the matched routes' modules in its manifest, and notes that CSS imported from an + async component isn't in the initial SSR links. **Remix/React Router, SvelteKit, SolidStart and + Astro** are route- or page-graph based too; SvelteKit and Astro inline stylesheets under a size + threshold. +6. **Compile-time CSS-in-JS**: vanilla-extract evaluates `.css.ts` files at build time (in a module + runner) and turns them into CSS imports plus class-name exports; Linaria (wyw-in-js) evaluates + only the slice of a file its styles depend on; StyleX and Panda extract statically and go atomic, + one stylesheet for the whole app, whose size grows with the number of distinct declarations + rather than components. All of them, again, are module-graph or whole-app. **Qwik** is the one + that goes by what renders: a component's `useStyles$` registers its styles when it renders, and + SSR writes only the rendered components' styles into the page. + +So nothing here can be reused as it is. Every Vite-based option ties CSS to modules, which is right +for client components (their code is loaded, so their CSS might as well be) and wrong for this +plan's main case: server components whose output is dynamic (a CMS's blocks, a search's results), +where a module-graph rule sends the CSS for every block type the page _could_ render. What can be +reused is the shape of the answers: rescript-css's API, vanilla-extract's build-time evaluation, +React's "a boundary doesn't reveal until its stylesheets have loaded", and Qwik's "send what +rendered". + +## How it works, end to end + +``` +compile Button.res ──ppx_weft──► Button.res.js: Css.class(...) calls become + Internal.class_("Button-button_wk3f9a", () => {...}) + + 'use weft css' directive + +vite scan for 'use weft css' ──► evaluate each file in a CSS module runner (Node), calling + the definitions ──► one sheet per file: wk3f9a.css + transform (client and rsc) ──► the definitions are stripped: only the names are left + build ──► each sheet a hashed asset; the manifest: sheet id → URL, and for each + entrypoint file the sheets its client module graph imports + +request Render walks the tree ──► host className props and strings in entrypoint props are + matched against sheet ids ──► a C row per sheet, first use; + J rows name the C rows they need; E rows bring their own sheets + Page ──► head held open until the shell is ready, then the shell's sheets as links + in the head; later sheets preloaded as they come; a segment's swap waits for + its sheets to load + +browser Decoder ──► a C row loads its sheet (a in the head, once per href); + a J row waits for its sheets as it waits for its entrypoints + navigation/refetch: new rows render in a transition, so nothing shows unstyled +``` + +Two ways for a class name to reach the DOM, and one rule for each: + +- **From the server**, in the payload: a server or shared component's output, or a string handed to + an entrypoint as a prop. The server sees it as it writes the payload, and sends the sheet with the + rows that use it: CSS by **what rendered**. +- **From client code**: an entrypoint (or anything it imports) rendering one of its own classes, + perhaps only after a click. That code is in the browser anyway, and its sheets come with it: CSS + by **what's loaded**, as every Vite framework does, but per style file rather than per chunk. + +Class names are constants the PPX writes (styles are static: dynamic values go through CSS +variables, §2), so there's no third way, short of building one by hand from a string. + +## Design + +### 1. The package: `weft_css` + +A new ReScript package, `@e-cline/weft_css`, namespace `WeftCss`, depending on nothing but +`@rescript/runtime`. Its main module is `Css`, so code reads `Css.class(...)` after `open WeftCss`. +The API follows rescript-css's, so its documentation mostly applies and code ports with few changes: + +| Call | Gives | CSS | +| ------------------------------------------------- | -------------- | ----------------------------------------------------------- | +| `Css.class(css)` | a class name | `.name { ... }` and its nested rules | +| `Css.style(css)` | a `Css.t` | none by itself: a nested block, or a fragment to reuse | +| `Css.global(~selector, css)` | `unit` | `selector { ... }` | +| `Css.var(initial)` | a `Css.var` | `--name: initial` on `:root`, in the declaring file's sheet | +| `Css.keyframes([Css.frame(~at, css), ...])` | a name | `@keyframes name { ... }` | +| `Css.fontFace({...})` (later) | a family name | `@font-face { ... }` | +| `Css.media(~query, css)`, `supports`, `container` | a condition | nested in the class's rule | +| `Css.layerOrder([...])`, `~layer=` on the above | | `@layer` (§5) | +| `Css.vars([(var, value)])` | a `style` dict | none: custom properties for `style=`, at run time | +| `Css.cx([a, b])`, `Css.cxOpt([Some(a), None])` | a class list | none | + +`css` is a record of optional fields, one per property, typed where it's worth it (`display`, +lengths, colours, `Var(v)`) and with `Raw(string)` everywhere as the way out; nested selectors are +fields too (`hover`, `focusVisible`, `disabled`, element names like `h2`, +`selectors: [("&[data-open]", Css.style(...))]`, `media: [...]`). Phase 1 starts with the properties +`weft_shop` uses, and grows; the property set is the bulk of the work and the least risky part +(rescript-css's `CssValue` is 2,000 lines of it), so it's done incrementally, with `Raw` covering +what's missing. + +`Css.style` is `external style: t => t = "%identity"`, so a fragment (`let base = Css.style({...})`) +compiles to a plain object literal, which bundles can drop once nothing uses it (an ordinary +function would make it a call, and its module impure). `Css.var` gives an abstract `Css.var`, its +name, which `Var(v)` serializes as `var(name)`. + +**Styles are static.** `Css.class`, `global`, `var`, `keyframes` and `fontFace` are allowed only in +`let` bindings at a module's top level (in any nesting of modules), and the PPX rejects them +anywhere else, with an error saying to use a CSS variable (`Css.vars`), a data attribute and a +`selectors` rule, or one class per variant. That's what lets every class have a name at compile +time, and every sheet be a file. + +**Without the PPX**, `Css.class` and the others throw when called ("weft_css: this file wasn't +compiled with ppx_weft"). They're called at module level, so the first import fails, at once; and +the Vite plugin checks `ppx-flags`, as it already does for `@weft.component`. + +### 2. The PPX (`ppx_weft`) + +`ppx_weft` gains the style calls. In a file that has any: + +```rescript +// src/client/AddToCart.res, written +module Styles = { + let button = Css.class({display: InlineFlex, hover: Css.style({background: "#115e59"})}) +} + +// what the compiler then sees +@@directive("'use weft css'") +module Styles = { + let button = Css.Internal.class_("AddToCart-Styles-button_wk3f9a", () => { + display: InlineFlex, + hover: Css.style({background: "#115e59"}), + }) +} +``` + +which bsc compiles, every time, to the shape the plugin rewrites (§3): + +```js +'use weft css'; +let button = Css$WeftCss.Internal.class_('AddToCart-Styles-button_wk3f9a', () => ({ + display: 'InlineFlex', + hover: {background: '#115e59'}, +})); +``` + +- **The name** is readable, then the file's **sheet id**: `w` and six base-36 digits of a hash + (32-bit FNV-1a, modulo 36⁶) of the file's id + (`/`, the same id entrypoints use, so it's the + same in every checkout). The plugin checks that no two files' sheet ids collide. The readable part + is the file's module name and the binding's path, made into a CSS identifier; it's unique within + the file, since bindings at one path are. Compiled output is committed and the same in development + and production, so names can't be shortened for production by the PPX; the plugin could rewrite + them later (Follow-ups). +- **The definition becomes a thunk.** At run time, in the browser and on the server, `class_` just + returns the name: definitions are never evaluated, so nothing of `Css`'s value layer runs or, once + the plugin strips the thunks (§3), is bundled. Extraction calls them. +- `var`, `keyframes` and `fontFace` get names the same way (`--Theme-accent_wk3f9a`), and `global` + an index in the file. A value naming another file's var or keyframes (`Var(Theme.accent)`) is a + string constant by then, so evaluating one file's definitions needs the other file's _names_, not + its definitions: the other file is imported as any module is, but its thunks aren't called. +- The call is recognized by its path's last two parts (`Css.class`, `WeftCss.Css.class`); a module + alias (`module C = Css`) isn't followed, and gets the run-time error above. The PPX is untyped, so + that's the best it can do, and it fails loudly rather than silently. + +- A style call anywhere else (in a function, a conditional, a nested `Css.class` inside a + definition) is a compile error at the call, naming the alternatives (§1). + +`'use weft css'` is how the plugin finds files with styles, as `'use weft entrypoint'` is for +entrypoints (§3). A file can be any kind (server, entrypoint, shared) and have styles too, so the +directive goes alongside whichever of the others the file gets (the spike only handled a file with +neither). + +**As built (phase 1).** + +- **Keywords are `@unboxed` variants** whose constructors are `@as` their CSS keyword, with a + `Raw(string)` catch-all (`InlineFlex` is the string `"inline-flex"` at run time), so serializing + one is the identity, and constructor names needn't kebab-case into the keyword (`Nowrap` for + `nowrap`). An unboxed variant can only have one string payload, so `display` has no `Var`: + `Raw("var(...)")` does it. Lengths and colours are ordinary variants, serialized by a `switch`. +- **The serializer is explicit**, a line per property in the order `Css.t` declares its fields + (`Serialize.declarations`), rather than walking the runtime object, whose key order a spread + changes. Selectors nest as rescript-css's do (`&` is the parent, no `&` is a descendant, lists are + crossed); at-rules wrap their block's rules. +- **`Extract` groups registrations by the sheet id their names end in**, so the collector is one + list for everything that ran while recording (a file imported by another records once, whoever + imports it first). Per sheet: variables on `:root` and globals in `weft.base`, classes and + keyframes in `weft.components`, any other `~layer` after those, each in declaration order; and the + sheet's dependencies, from the other sheet ids in its text. It throws for a definition that throws + (naming it), a name registered twice, and a name with no sheet id. +- **What the PPX writes** compiles positionally: `Internal.class_(name, layer, () => ...)` and + `Internal.global_(name, selector, layer, () => ...)`, with `undefined` for an absent layer. + Globals are named `-global`. A nested style call is checked for before anything else, + so its error is the "static" one even in a file with no `rescript.json`; the missing + `rescript.json` error says `Css.class:` rather than `@weft.component:`. +- **The hash, twice**: OCaml hashes the file id's bytes, so `SheetId` hashes its UTF-8 encoding + (`TextEncoder`), not UTF-16 code units; a fixture under `fixtures/größe/` pins the two together. + bsc compiles `x >>> 0` to `x` (a no-op in its int model), so the unsigned value is made in floats. +- **Left for later**: `Css.fontFace` and `registerProperty` (nothing uses them yet), and + `Css.layerOrder`, which is the page's to declare (phase 3). + +### 3. Extraction (the Vite plugin) + +The `weft` plugin gains the CSS work. (Rather than a separate plugin: delivery needs the payload and +the manifest, which are `weft`'s. A client-only app gets a smaller `weftCss()` export, §8.) + +**Finding style files.** The scan that finds entrypoint files also collects files with +`'use weft css'`, from the app's sources and its ReScript dependencies' (so a component library's +styles work), and computes each one's file id and sheet id, checking them against the names in its +`class_` calls. + +**Evaluating them.** A **CSS module runner**, a Vite environment of the plugin's own (`weft_css`: +environment names may only have letters, digits, `$` and `_`; Node, resolving as the client does, +conditions included, so `Common.Env.isDev` is the same), imports each style file with a collector +installed; each `class_`, `global` and so on records its name and thunk, and the plugin calls the +thunks and serializes the results, file by file, into CSS text. One **sheet per style file**, with +its globals, vars, keyframes and font faces, and its classes in the order they're declared. + +Evaluating a file runs its top level and its imports' (not their thunks). That's already a +requirement of a server-rendered Weft app: server files run in `rsc`, and every entrypoint file, +with what it imports, is imported on the server for SSR (`virtual:weft/ssr-references`). Client +modules that touch `window` at load time would break both; Weft's own don't (`WeftDom.Jsx` loads +without a DOM). The runner is separate from `rsc` so extraction never shares state with request +handling, and runs in builds as well as in development (`createRunnableDevEnvironment`, as the +plugin's dev server does today). In the spike, every one of `weft_shop`'s and `weft_notes`' +component files, server and client, evaluated in such a runner with no DOM (45 files in about 250 +ms). What failed were **entry modules**: the server entry (it imports `virtual:weft/assets`) and +`weft_app`'s `Main` (it renders into `document` when loaded). The runner only imports style files +(and what they import), so an entry module matters only if it has styles itself; extraction then +fails with the file and the error, saying to move the styles to a module of their own. Slicing files +down to what their styles need, as wyw-in-js does, is the fallback if top-level side effects turn +out to be a problem in practice (Risks). + +**Stripping.** In every environment's `transform`, a style file's `class_(name, thunk)` calls are +replaced by the name itself, a string literal, using Vite's parser (the calls are the PPX's, in a +fixed shape, but the plugin uses a real AST, not patterns), and the directive is blanked as the +others are. With no calls left, the module's top level is pure, and bundles tree-shake `Css`'s value +layer away; the server doesn't need it either. + +**Sheets as files.** Each sheet is a CSS module whose content the plugin supplies, imported with +`?url` (`virtual:weft/css/.css?url`), which runs it through Vite's CSS pipeline (PostCSS +or Lightning CSS, minification and prefixing, `url()` assets) and, in a build, emits it as a hashed +asset of its own, with a manifest entry: not merged into chunks' CSS, because the same sheet is +needed from two directions (§4) and must be one URL to be loaded once. The plugin resolves the id to +a **path-like** id (`/node_modules/.weft-css/.css`, with no file there) and loads +its content: a `\0`-prefixed virtual id breaks Vite's CSS plugin, which imports the module again as +`…?transform-only` and can't find it. (Both found in the spike.) + +**The manifest** (in `virtual:weft/assets`, built from the client build): + +- `sheets`: sheet id → URL (in development, the dev server's URL for the virtual module); +- `entrypoints`: each entrypoint file's chunks, as now, and also its **sheets**: those of every + style file in its client module graph (in a build, from the chunks' module ids; in development, + from the client environment's module graph, or the scan's own import graph of compiled files); +- `styles`: the bootstrap's own sheets, as now. + +**As built (phase 2).** In `tools/weft_vite_plugin/src/internal/`: `css.ts` (sheet modules, +stripping, the `Extractor`), `cssPlugin.ts` (the plugin `weft()` includes and `weftCss()` is), and +the scan's sheet ids (`scan.ts`). + +- **Recording is per copy of `weft_css`, not on `globalThis`.** The runner shares the process with a + dev server's `rsc` environment, which may render a request (and so run style files' top levels) + while an extraction is recording. The runner has its own copy of `weft_css` (`noExternal`), the + plugin calls `Extract` through it, and only that runner's files reach it. (Inside `Css`, the + recording is a flag and an array: the keyword types' `None` shadows `option`'s.) +- **The runner is a Vite server of the plugin's own** (no config file, no watcher), whose `weft_css` + environment is runnable, in builds as in development. A build extracts every style file at + `buildStart` (once, for both environments) and closes it; a dev server extracts on the first + request for a sheet and keeps it, and when ReScript rewrites a style file, drops that file from + the runner (`evaluatedModules.invalidateModule`) and imports it again, which records only it (its + imports have run already). An app with no style files never starts it, so it needn't have + `weft_css`. +- **An extraction error names the file imported, which may not be the one that threw** (a file the + style file imports): the message says "importing it (or a file it imports) threw". +- **Sheets resolve to one path however they're asked for**: `virtual:weft/css/.css`, the path, + and the dev server's URL for it (`/node_modules/.weft-css/.css`), so a sheet is one module, + and a dev edit invalidates the one the page has. +- **Stripping** replaces a style call with its name and a global with `void 0`, blanking the rest of + the call in place, through Vite's `parseAst`. The CSS plugin runs before `weft()`'s own, and + blanks only `'use weft css'`, leaving the others for it. +- **`weftCss()`'s files import their sheets** (`import "virtual:weft/css/.css"`, on the line the + directive was), in client environments only. `weft()`'s import nothing yet: the manifest of + sheets, and the pages linking them, are phase 3. +- **Tests**: `css.test.ts` (resolving, stripping in place), the scan's (sheet ids, a name with + another id, two files with one), `weftCss.test.ts` (on the new `styled` fixture: a build, its + sheets with the entry's and a lazy chunk's CSS, no definitions or serializer in the JavaScript, a + definition that throws, a file needing the browser, a missing PPX; a dev server serving a stripped + file and its sheet, and extracting a rewritten file again), and the `basic` build's (no definition + in either bundle). +- **`weft_app`** moved its inline `
diff --git a/examples/weft_app/package.json b/examples/weft_app/package.json index b8865e0..a937a73 100644 --- a/examples/weft_app/package.json +++ b/examples/weft_app/package.json @@ -6,11 +6,13 @@ "type": "module", "dependencies": { "@e-cline/weft": "workspace:*", + "@e-cline/weft_css": "workspace:*", "@e-cline/weft_dom": "workspace:*", "@rescript/runtime": "catalog:" }, "devDependencies": { "@e-cline/ppx_weft": "workspace:*", + "@e-cline/weft_vite_plugin": "workspace:*", "jsdom": "catalog:", "rescript": "catalog:", "rescript-vitest": "catalog:", diff --git a/examples/weft_app/rescript.json b/examples/weft_app/rescript.json index 1b1cc18..d5155d4 100644 --- a/examples/weft_app/rescript.json +++ b/examples/weft_app/rescript.json @@ -13,6 +13,6 @@ "suffix": ".res.js", "namespace": "WeftAppExample", "dev-dependencies": ["rescript-vitest"], - "dependencies": ["@e-cline/weft", "@e-cline/weft_dom"], + "dependencies": ["@e-cline/weft", "@e-cline/weft_css", "@e-cline/weft_dom"], "ppx-flags": ["@e-cline/ppx_weft/ppx"] } diff --git a/examples/weft_app/src/App.res b/examples/weft_app/src/App.res index 252f32a..931d795 100644 --- a/examples/weft_app/src/App.res +++ b/examples/weft_app/src/App.res @@ -36,6 +36,7 @@ module NewTodo = {
unit) => { let id = Int.toString(todo.id) -
  • +
  • , ~dispatch: Todo.action => unit) => { let filter = useContext(FilterContext.context) -