Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/weft-server-rpc-stylesheets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@e-cline/weft_server': patch
---

RPC answers bring their stylesheets: `RpcServer.respond` takes `~assets` (`virtual:weft/assets`, as
`Page.respond` does), and an answer with elements in it sends the sheets of their classes, as a
page's payload does. The client's call resolves once they've loaded, so what it renders never shows
without its CSS. Without `~assets`, nothing changes. `Page.stylesOf` gives the stylesheets in an
app's assets, as `Render` takes them.
31 changes: 23 additions & 8 deletions docs/weft_css_plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ for all of them). The decisions were taken as recommended (Decisions). The desig
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.
phase 6's after §7, and phase 7's after §8. What's left to do is under Follow-ups, at the end;
follow-up 1 (RPC responses' sheets) is done since, with its own "As built" note there.

The things most worth knowing, for whoever works on this next:

Expand Down Expand Up @@ -468,8 +469,8 @@ other way (a `Weft.Lazy` around an `import()` in a client-only app), and develop
stylesheet links for the `C` rows written so far, the entrypoints' `modulepreload`s (which used to
be at the start of the body), and `</head><body>`. Payload responses (navigation) get `C` rows
too. A page without HTML (`html=false`) closes its head at once, as before: its sheets are the
client's to load. RPC responses (`RpcServer`) don't send sheets yet: an element an RPC returns is
styled only by what's already loaded (phase 4).
client's to load. RPC responses (`RpcServer`) didn't send sheets yet: an element an RPC returned
was styled only by what had loaded already (follow-up 1 added them).
- **The layer order is in every sheet**, `@layer weft.base, weft.components;` first
(`Extract.layerOrder`), not in the page's head: the first stylesheet to name a layer fixes its
place, whichever that is, and `weftCss()` apps, with no page of Weft's, need it as much.
Expand Down Expand Up @@ -895,12 +896,26 @@ Settled in review: each recommendation below was taken.

## Follow-ups

What's left, most useful first. The first two are the gaps in what's sent:
What's left, most useful first. The first two are the gaps in what's sent (the first is done):

1. **Sheets for RPC responses** (done). `RpcServer` encoded without `~styles`, so an element an RPC
returned (`weft_shop`'s "Load more" cards) was styled only by sheets already loaded.

**As built.** `RpcServer.respond` takes `~assets` (an app's route passes `virtual:weft/assets`,
as it does to `Page.respond`), and encodes the answer with their styles (`Page.stylesOf`, now
public): the `C` rows of what the answer renders come before the rows needing them, as in a
page's payload. The client needed nothing: `Rpc.call` decodes with `Decoder.valueFromStream`,
which already holds the root back until its sheets load, so the call resolves only then, and what
the caller renders with it is styled; a later row (a card's stock) waits as any other does. On
the router rather than `respond` would have meant giving the assets to the module that lists the
handlers, which has no reason to import them. `weft_shop`'s cards gained a sale badge, in a file
of its own (`SaleBadge`), which is what the test needs: sorted dearest first, the shoes' first
page has nothing on sale and "Load more" brings two. Tests: `weft_server`'s `Sheets_test` ("RPCs,
with sheets": the answer held back until its sheet loads; a boundary in it keeping its fallback,
after the answer, until its later row's sheet loads; without assets, no sheets), and
`weft_shop`'s `Styles_test` (the grid keeps its old cards, "Loading…", until the badge's sheet
loads, and nothing is ever unstyled). Both fail with the styles left out.

1. **Sheets for RPC responses**: `RpcServer` encodes without `~styles`, so an element an RPC returns
(`weft_shop`'s "Load more" cards) is styled only by sheets already loaded. They are there in
`weft_shop` (the grid's first cards brought them), but needn't be. Small: `RpcServer` given the
assets, as `Page` is.
2. **Style modules loaded some other way** (a `Weft.Lazy` around an `import()`, in a `weft()` app)
bring no sheets: entrypoints' come with the rows using them, but nothing registers a module's own
when the client runs it (§4's "client code's own sheets"). Small.
Expand Down
2 changes: 1 addition & 1 deletion examples/weft_notes/src/server/Server.res
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ let noteRoutes = [
),
// The RPCs (`Api`'s, answered by `ApiServer`'s handlers).
Route.post(list{"_weft", "rpc", ":id"}, async ctx =>
await ApiServer.router->WeftServer.RpcServer.respond(ctx.request, ~id=id(ctx))
await ApiServer.router->WeftServer.RpcServer.respond(ctx.request, ~id=id(ctx), ~assets)
),
]

Expand Down
2 changes: 1 addition & 1 deletion examples/weft_notes/src/server/Server.res.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions examples/weft_shop/src/server/SaleBadge.res
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
@@weft.server

/***
A card's sale badge: how much a product on sale is off. Its styles are its own file's (a sheet of
their own), so a page sends them only if it shows something on sale.
*/

open Types
open WeftCss

module Styles = {
let badge = Css.class({
display: InlineBlock,
padding: Pair(Px(2), Px(8)),
borderRadius: Px(999),
backgroundColor: Var(Theme.sale),
color: Named("white"),
fontSize: Px(12),
fontWeight: 600,
})
}

/** The badge for `summary`, if it's on sale. */
@weft.component({kind: Server})
let make = (~summary: summary) =>
switch summary.compareAt {
| Some(was) if was > summary.price =>
let off = Math.round(Int.toFloat(was - summary.price) /. Int.toFloat(was) *. 100.0)
<span className={Styles.badge} dataId={`sale-${summary.id}`}>
{WeftDom.Jsx.string(`${Float.toString(off)}% off`)}
</span>
| _ => WeftDom.Jsx.null
}
68 changes: 68 additions & 0 deletions examples/weft_shop/src/server/SaleBadge.res.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion examples/weft_shop/src/server/Server.res
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ let routes = [
),
// The RPCs (`Api`'s, answered by `ApiServer`'s handlers).
Route.post(list{"_weft", "rpc", ":id"}, async ctx =>
await ApiServer.router->WeftServer.RpcServer.respond(ctx.request, ~id=param(ctx, "id"))
await ApiServer.router->WeftServer.RpcServer.respond(ctx.request, ~id=param(ctx, "id"), ~assets)
),
]

Expand Down
2 changes: 1 addition & 1 deletion examples/weft_shop/src/server/Server.res.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions examples/weft_shop/src/server/Ui.res
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ module ProductCard = {
<div className={Theme.picture} style={Theme.pictureVars(~hue=summary.hue)} />
<h3 className={Styles.cardName}> {text(summary.name)} </h3>
</Link>
<SaleBadge summary />
<div className={Styles.cardMeta}>
<Price cents=summary.price compareAt=?summary.compareAt />
<WishlistButton productId=summary.id saved />
Expand Down
4 changes: 4 additions & 0 deletions examples/weft_shop/src/server/Ui.res.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions examples/weft_shop/tests/Styles_test.res
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,36 @@ describe("the store's styles", () => {
await page->WeftTesting.waitFor(() => has(page, "[data-id=cart-title]"))
t->expect(page->WeftTesting.unstyled)->Expect.toEqual([])
})

testAsync(
"bring the sheets of what an RPC renders: \"Load more\" shows its cards once their sheets load",
async t => {
await app()->releaseAll
// Dearest first: the first page has nothing on sale, the second has two.
let page = app()->open_("/c/shoes?sort=price-desc")
await page->WeftTesting.waitFor(() => has(page, "[data-id=load-more]"))
await hydratedAsServed(page)
t->expect(has(page, "[data-id^=sale-]"))->Expect.toBe(false)
let head = headSheets(await page->WeftTesting.shell)
t->expect(head->hasSheet("server/SaleBadge"))->Expect.toBe(false)
t->expect(page->WeftTesting.stylesheets->hasSheet("server/SaleBadge"))->Expect.toBe(false)
page->WeftTesting.holdStylesheets(~matching=sheetOf("server/SaleBadge"))
page->WeftTesting.click("[data-id=load-more]")
await page->WeftTesting.waitFor(
() =>
page->WeftTesting.stylesheets->hasSheet("server/SaleBadge") ||
has(page, "[data-id=card-p6]"),
)
await page->WeftTesting.settle
// The answer is in, but for its badges' sheet: the grid stays as it was.
t->expect(text(page, "[data-id=load-more]"))->Expect.toBe("Loading…")
t->expect(has(page, "[data-id=card-p6]"))->Expect.toBe(false)
t->expect(page->WeftTesting.unstyled)->Expect.toEqual([])
page->WeftTesting.releaseStylesheets
await page->WeftTesting.waitFor(() => has(page, "[data-id=sale-p6]"))
t->expect(text(page, "[data-id=sale-p6]"))->Expect.toBe("20% off")
t->expect(has(page, "[data-id=sale-p3]"))->Expect.toBe(true)
t->expect(page->WeftTesting.unstyled)->Expect.toEqual([])
},
)
})
28 changes: 28 additions & 0 deletions examples/weft_shop/tests/Styles_test.res.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/weft_server/src/server/Page.res.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions packages/weft_server/src/server/Page.resi
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ type assets = {
entrypointSheets?: dict<array<string>>,
}

/**
The app's stylesheets in `assets`, as `Render` takes them (its `styles`): `None` if the app has
none.
*/
let stylesOf: assets => option<Render.styles>

/**
The HTML page for `element`, streamed as it renders. `url` (the request's path and query, `"/"` by
default) is what the server-side page says it is; `html=false` leaves the HTML out. `title` and
Expand Down
3 changes: 2 additions & 1 deletion packages/weft_server/src/server/RpcServer.res
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ let readBody = async (request, ~limit) =>
}
}

let respond = async (router, request: Request.t, ~id) =>
let respond = async (router, request: Request.t, ~id, ~assets=?) =>
switch router.handlers->Map.get(id) {
| None => refuse(404, `There's no RPC ${id}.`)
| Some(handler) =>
Expand Down Expand Up @@ -232,6 +232,7 @@ let respond = async (router, request: Request.t, ~id) =>
toUnknown(root),
~onError=router.onError,
~signal=toSignal(request.signal),
~styles=?assets->Option.flatMap(Page.stylesOf),
),
)
| exception error => payloadResponse(500, failure(router, error))
Expand Down
5 changes: 3 additions & 2 deletions packages/weft_server/src/server/RpcServer.res.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion packages/weft_server/src/server/RpcServer.resi
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ let addComment = WeftServer.RpcServer.handler(Api.addComment, async (request, ~h
let router = WeftServer.RpcServer.router([addComment])

// src/Server.res
@module("virtual:weft/assets") external assets: WeftServer.Page.assets = "default"

Route.post(list{"_weft", "rpc", ":id"}, async ctx =>
await ApiServer.router->WeftServer.RpcServer.respond(
ctx.request,
~id=ctx->RequestContext.paramOrThrow("id"),
~assets,
)
)
```
Expand Down Expand Up @@ -68,8 +71,13 @@ The response to `request`, a call of the RPC `id`. In order:
6. otherwise the handler's result is the answer, as a payload, with `Payload.cacheControl`. A
handler that throws is a 500 (`Failed`), with the digest `onError` gave it and, in development
only, its message.

With `assets` (`virtual:weft/assets`, as `Page.respond` is given), the answer brings the
stylesheets of the elements in it, as a page's payload does (see `Render`'s `styles`), and the
client's call resolves once they've loaded, so what it renders never shows without its CSS. Without
them, an element in the answer is styled only by what the page has loaded already.
*/
let respond: (router, Request.t, ~id: string) => promise<Response.t>
let respond: (router, Request.t, ~id: string, ~assets: Page.assets=?) => promise<Response.t>

/** The IDs the router answers, sorted. */
let ids: router => array<string>
Loading
Loading