diff --git a/.changeset/vite-plugin-weft-testing-cookies.md b/.changeset/vite-plugin-weft-testing-cookies.md
new file mode 100644
index 0000000..3b38da4
--- /dev/null
+++ b/.changeset/vite-plugin-weft-testing-cookies.md
@@ -0,0 +1,8 @@
+---
+'@e-cline/weft_vite_plugin': patch
+---
+
+The test harness keeps cookies, as a browser does for the app's origin: what the server's
+`Set-Cookie` headers set is sent with every request after, until it expires (`cookies`,
+`clearCookies`). Opening a page closes the one before it (`closePage`): its root is unmounted, so
+its intervals, subscriptions and history listener stop, and can't act in the next test.
diff --git a/.changeset/vite-plugin-weft-testing-redirects.md b/.changeset/vite-plugin-weft-testing-redirects.md
new file mode 100644
index 0000000..096e751
--- /dev/null
+++ b/.changeset/vite-plugin-weft-testing-redirects.md
@@ -0,0 +1,9 @@
+---
+'@e-cline/weft_vite_plugin': patch
+---
+
+The test harness's `fetch` follows redirects on the app's origin as a browser's does (a 303, or a
+POST's 301 or 302, with a `GET`; at most 20; `redirect: 'manual'` and `'error'` respected), and its
+responses say where they ended up (`url`, `redirected`); a redirect to another origin fails. A page
+opened at a URL that redirects is at the URL it led to (`WeftTesting.url`), and payloads report it
+to `Navigation`, as in a browser.
diff --git a/.changeset/weft-core-dehydrated-retry.md b/.changeset/weft-core-dehydrated-retry.md
new file mode 100644
index 0000000..3be7c66
--- /dev/null
+++ b/.changeset/weft-core-dehydrated-retry.md
@@ -0,0 +1,8 @@
+---
+'@e-cline/weft': patch
+---
+
+Fixes a pending `Suspense` boundary rendered twice: when its content arrived after hydration had
+started, and suspended as it hydrated (an entrypoint's chunk still loading, say), the boundary
+rendered its content on the client after the server's, which stayed, unhydrated. It now stays as the
+server sent it until what it waits on settles, then hydrates.
diff --git a/.changeset/weft-dom-hydration-and-props.md b/.changeset/weft-dom-hydration-and-props.md
new file mode 100644
index 0000000..5351300
--- /dev/null
+++ b/.changeset/weft-dom-hydration-and-props.md
@@ -0,0 +1,11 @@
+---
+'@e-cline/weft_dom': patch
+---
+
+- `autoFocus` focuses an element rendered after the page has loaded (a dialog's field, say), once
+ the commit has put it in the page: the attribute alone only works for elements there as the page
+ loads.
+- Hydration compares `style` values as the browser reads them back, so the same style written
+ another way (`hsl(...)`, read back as `rgb(...)`; `-0%` as `0%`) isn't reported as a difference.
+- New props: `ariaExpanded`, `ariaSelected`, `ariaControls`, `ariaCurrent`, `ariaHidden`,
+ `ariaLive`, `ariaModal`, `ariaBusy`, `required`, `autoComplete`, `inputMode` and `maxLength`.
diff --git a/.changeset/weft-server-navigation-commit.md b/.changeset/weft-server-navigation-commit.md
new file mode 100644
index 0000000..9bd54bc
--- /dev/null
+++ b/.changeset/weft-server-navigation-commit.md
@@ -0,0 +1,9 @@
+---
+'@e-cline/weft_server': patch
+---
+
+- `Navigation.navigate`, `load` and `refresh` resolve once the new page is on screen (its transition
+ has committed), as documented, rather than as soon as it starts rendering: code awaiting them (to
+ clear a pending state, say) no longer runs while the old page is still showing.
+- `Bootstrap.start` takes a `~signal`: aborting it stops the page (its root unmounted, the history
+ no longer followed), for tests that open one page after another in a document.
diff --git a/.changeset/weft-server-redirects.md b/.changeset/weft-server-redirects.md
new file mode 100644
index 0000000..e920195
--- /dev/null
+++ b/.changeset/weft-server-redirects.md
@@ -0,0 +1,11 @@
+---
+'@e-cline/weft_server': patch
+---
+
+A navigation the server redirects lands where the redirect led. `Decoder.fetch` now resolves with
+`Decoder.fetched` (the payload's `stream`, and the `url` a redirect led to, if one did) instead of
+the bare stream; `webFetch` reports where the browser's `fetch` ended up. `Navigation.url` is that
+URL, `navigate` calls `onNavigate` with it (so the browser's history gets it, not the URL asked
+for), and a `load` or `refresh` that was redirected calls the new `~onRedirect` with it (the
+browser's replaces the history's entry). `Decoder.Latest.fetchElement` resolves with the element and
+its URL (`Decoder.Latest.fetched`).
diff --git a/.changeset/weft-signals-server.md b/.changeset/weft-signals-server.md
new file mode 100644
index 0000000..8a11a8f
--- /dev/null
+++ b/.changeset/weft-signals-server.md
@@ -0,0 +1,7 @@
+---
+'@e-cline/weft_signals': patch
+---
+
+The hooks work in an entrypoint rendered on the server (to HTML): they give the signal's value
+there, where they failed before (`useSyncExternalStore` without a server snapshot, error 15), and
+the page fell back to rendering on the client.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4c1d2ad..47e6515 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -53,8 +53,9 @@ jobs:
- run: vp run test
e2e:
- # The browser tests (examples/weft_notes/e2e): a few, against the built app in Chromium, on one
- # Node version. Everything a browser isn't needed for is tested in the job above.
+ # 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
+ # above.
name: Browser tests
runs-on: ubuntu-latest
steps:
@@ -80,3 +81,4 @@ jobs:
pnpm --filter @e-cline/weft_notes_example exec playwright install
--with-deps chromium
- run: vp run --filter @e-cline/weft_notes_example e2e
+ - run: vp run --filter @e-cline/weft_shop_example e2e
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 47f93ce..89f2413 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -70,8 +70,9 @@ pnpm run build
pnpm run test
```
-Run the browser tests (a few, in [`examples/weft_notes/e2e`](examples/weft_notes/e2e), against the
-built app), which need Playwright's Chromium installed once:
+Run the browser tests (in [`examples/weft_notes/e2e`](examples/weft_notes/e2e) and
+[`examples/weft_shop/e2e`](examples/weft_shop/e2e), against the built apps), which need Playwright's
+Chromium installed once:
```bash
pnpm --filter @e-cline/weft_notes_example exec playwright install chromium
diff --git a/README.md b/README.md
index 9f03be7..2040479 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,8 @@ 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` and
-`weft_split` examples.
+components for it are under way: `weft_server` and `weft_vite_plugin`, with the `weft_notes`,
+`weft_split` and `weft_shop` examples.
## Packages
diff --git a/examples/weft_shop/README.md b/examples/weft_shop/README.md
new file mode 100644
index 0000000..76d0fe0
--- /dev/null
+++ b/examples/weft_shop/README.md
@@ -0,0 +1,130 @@
+# weft_shop
+
+A storefront, "Loom & Co.", built with [Weft's server components](../../packages/weft_server): the
+largest of the examples, and a test bed for how Weft's features work together, with the latency of
+real services in between. Its home and category pages are server-driven: what they're made of is
+data the server decides on for each request, and each part of them streams in by itself.
+
+- **Server-driven pages** ([`src/server/Cms.res`](src/server/Cms.res),
+ [`src/server/Blocks.res`](src/server/Blocks.res)): the home page and each category's banner are a
+ tree of blocks (heroes, rails of products, promotions, tabs, columns, live deals, an experiment)
+ kept as data and changed at runtime, and personalised: signed-in shoppers are welcomed, get
+ recommendations and a rail to buy again, and each half of the experiment sees its own promotion.
+ Every block is a server component waiting on its own service, in a `Suspense` boundary of its own,
+ inside a `BlockFrame` entrypoint: an error boundary (a block that fails says so, with Retry) that
+ fetches the block again from its own route (`useRefetchable`), on Retry or, for live deals, every
+ few seconds. Containers render their blocks the same way, so a rail in a tab streams, fails and
+ refetches by itself too, and blocks keep their state when a refresh brings a layout that moved
+ them.
+- **Streaming everywhere**: the shell first, then the layout, then each block, then each card's
+ stock, each under its own boundary; a product's offer, reviews (a promise, read on the client with
+ `Hooks.use`), rails and questions; an order's delivery estimate.
+- **The catalog** ([`src/server/Pages.res`](src/server/Pages.res)): filters the server decides on
+ per category (`FacetPanel`), each change a navigation to the page's new URL (showing what was
+ picked until it's rendered), with the results in a grid (`ProductGrid`) keyed by the filters, a
+ quick filter over the cards already there (`useDeferredValue`), and "Load more" from the
+ `catalog.more` RPC, whose answer is the cards themselves, rendered on the server, their stock
+ still streaming.
+- **Search**: suggestions as you type (`search.suggest`, through `Rpc.call` with an `AbortSignal`,
+ so each query cancels the one before it), results on Enter, and the box in the header keeping what
+ was typed as the shopper navigates.
+- **A product's page**: variants whose offer (price, stock, the add to cart button) is fetched again
+ by itself (`ProductOffer`), keeping the quantity chosen; a gallery; reviews sorted in a
+ transition.
+- **The cart, client and server** ([`src/client/Store.res`](src/client/Store.res),
+ [`src/client/ShopContext.res`](src/client/ShopContext.res)): the cart an RPC answers with goes to
+ a `signals` atom, which `ShopShell` (the entrypoint around every page, providing the page's
+ context) reads and hands down, newest of its and the server's, so the header's count, the drawer
+ (a portal) and the cart page follow at once; the cart page's quantities change before the server
+ answers (a reducer), and its totals, rendered on the server, are fetched again whenever the cart's
+ version goes up.
+- **Context and currency**: every price is a `Price` entrypoint reading the currency from
+ `ShopContext`, so picking one re-renders every price on the page with nothing fetched, and the
+ `prefs.setCurrency` RPC makes it the session's, for the next pages the server renders.
+- **Sessions and auth** ([`src/server/Session.res`](src/server/Session.res)): an `sid` cookie that
+ `@e-cline/servy` middleware sets on the first response without one, naming what the server keeps
+ for the shopper; signing in (a name, no password: a demo) from a dialog rendered into `
` (a
+ portal) that takes the focus (`autoFocus`), or from the sign-in page the account page redirects a
+ signed-out shopper to (a navigation lands at the URL a redirect leads to); RPC handlers finding
+ the session from the HTTP request, even on a visitor's first request.
+- **Checkout**: a three-step form (a reducer, labels linked by `useId`; an empty cart is redirected
+ back to the cart), the address checked as the RPC will check it before moving on, and the order
+ refused, saying which item, if someone else bought the last of it first. The order's page
+ refreshes itself until it's delivered.
+- **RPCs** ([`src/shared/Api.res`](src/shared/Api.res), handled in
+ [`src/server/ApiServer.res`](src/server/ApiServer.res)): eleven, with object, variant, option and
+ custom codecs, `prepare` steps that send nothing for an empty promo code or name, `check`s on both
+ sides, and errors the shopper is told about.
+- **The rest**: an offline banner (`useSyncExternalStore` with a server snapshot), a countdown that
+ renders the server's time first so hydration matches, toasts, and a stylesheet in each page's
+ head.
+
+The "database" ([`src/server/Db.res`](src/server/Db.res)) is in memory and the same every time.
+Every slow call waits on [`src/server/Latency.res`](src/server/Latency.res): in `vp dev` and
+`vp run start`, a random time from a seeded generator, with the recommendations and questions
+services failing now and then; in the tests, nothing opens until the test says. `WEFT_SHOP_LATENCY`
+(`random`, `fixed`, `off` or `manual`), `WEFT_SHOP_SEED` and `WEFT_SHOP_FAIL_RATE` set it.
+
+Run it:
+
+```bash
+pnpm --filter @e-cline/weft_shop_example exec vp run dev
+```
+
+or build it and serve the build:
+
+```bash
+pnpm --filter @e-cline/weft_shop_example exec vp run start
+```
+
+## Tests
+
+[`tests/`](tests) runs the app's scenarios through
+[the plugin's harness](../../tools/weft_vite_plugin#testing-an-app): the server in a worker thread
+(with its controls, [`tests/ShopControl.res`](tests/ShopControl.res), called there), each page in
+jsdom, cookies kept as a browser keeps them. Each test starts with every latency gate closed, and
+opens them in the order it's about:
+
+- [`Home_test.res`](tests/Home_test.res): the home page streaming block by block in any order, and
+ in chunks of any size; a boundary whose content arrives while an entrypoint's chunk is still
+ loading hydrated once it has, with nothing rendered twice; navigating away from a page still
+ streaming, which stops its services; blocks working once hydrated; a refresh bringing a layout
+ with blocks moved, added and removed, each keeping its state; the experiment; a block that fails,
+ and Retry; live deals refetching themselves; personalisation after signing in, and recommendations
+ failing; the currency changing every price at once; a page closed when the next opens.
+- [`Catalog_test.res`](tests/Catalog_test.res): a category page's blocks, filters and results
+ streaming by themselves; "Load more" (server-rendered cards from the RPC, their stock streaming
+ after); the quick filter, with nothing fetched; filters and sort as navigations, the grid starting
+ afresh, the results staying until the new ones are ready, the range reset when the filters are
+ cleared; search suggestions, a newer query cancelling an older one's request; search results.
+- [`Product_test.res`](tests/Product_test.res): the offer, reviews, rail and questions streaming by
+ themselves; hydration in chunks of any size; another variant's offer fetched by itself, keeping
+ the quantity (clamped to the stock); adding to the cart, refused past the stock; the drawer; a
+ sold out variant; reviews sorted in a transition; the questions failing, and Retry; the wishlist,
+ signed out and in, and put back when the server says no.
+- [`Cart_test.res`](tests/Cart_test.res): the cart's lines and server-rendered totals; quantities
+ changed at once, the totals following by themselves; promo codes (an empty one never sent, unknown
+ and expired ones refused); checking out, the address checked on the client, the order placed, and
+ its page refreshing itself until it's delivered; an item that ran out first; the session's cookie,
+ even for an RPC that's a visitor's first request; the account page redirecting to sign in, and
+ back after, and out again; an empty cart's checkout redirecting to the cart; names refused on both
+ sides.
+
+[`e2e/`](e2e) checks, in Chromium against the build, what only a browser can, served twice:
+
+- [`shop.e2e.ts`](e2e/shop.e2e.ts), with the latency gated (`WEFT_SHOP_TEST_ROUTES=1` adds routes
+ that open the gates): the home page streaming; blocks arriving while a chunk is held back,
+ hydrated once it's loaded, not rendered twice; a whole purchase, filter to order; the sign-in
+ dialog's focus and Escape; links to pages that redirect, landing at the redirect's URL; the
+ currency kept across a reload; the back and forward buttons through filtered pages; a failed block
+ retried; and the pages readable with JavaScript off.
+- [`random.e2e.ts`](e2e/random.e2e.ts), with seeded random latency and failures, as `vp run start`
+ serves it: every page streaming in completely, once, and hydrating; navigating quickly through
+ pages still streaming; and a purchase, retrying whatever fails.
+
+Each with nothing in the console. Run them (they need Chromium:
+`pnpm --filter @e-cline/weft_shop_example exec playwright install chromium`):
+
+```bash
+pnpm --filter @e-cline/weft_shop_example exec vp run e2e
+```
diff --git a/examples/weft_shop/e2e/playwright.config.ts b/examples/weft_shop/e2e/playwright.config.ts
new file mode 100644
index 0000000..3d8773c
--- /dev/null
+++ b/examples/weft_shop/e2e/playwright.config.ts
@@ -0,0 +1,59 @@
+import {fileURLToPath} from 'node:url';
+
+import {defineConfig, devices} from '@playwright/test';
+
+// The browser tests, against the build (`vp run e2e` builds it first), served twice by `serve.mjs`:
+// once with its latency gated (`manual`, opened by the tests through the test-only routes), for
+// tests that choose the order things arrive in; and once with seeded random latency and failures,
+// as `vp run start` serves it, for tests that only say what must happen eventually.
+const gated = Number(process.env.WEFT_SHOP_E2E_PORT ?? 4417);
+const random = gated + 1;
+const cwd = fileURLToPath(new URL('..', import.meta.url));
+
+export default defineConfig({
+ testDir: '.',
+ fullyParallel: false,
+ // One server (and one database) each: the tests take turns.
+ workers: 1,
+ reporter: process.env.CI ? 'github' : 'list',
+ projects: [
+ {
+ name: 'gated',
+ testMatch: 'shop.e2e.ts',
+ use: {...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${gated}`},
+ },
+ {
+ name: 'random latency',
+ testMatch: 'random.e2e.ts',
+ use: {
+ ...devices['Desktop Chrome'],
+ baseURL: `http://127.0.0.1:${random}`,
+ },
+ },
+ ],
+ webServer: [
+ {
+ command: 'node serve.mjs',
+ cwd,
+ url: `http://127.0.0.1:${gated}/test/ready`,
+ env: {
+ PORT: String(gated),
+ WEFT_SHOP_TEST_ROUTES: '1',
+ WEFT_SHOP_LATENCY: 'manual',
+ },
+ reuseExistingServer: false,
+ },
+ {
+ command: 'node serve.mjs',
+ cwd,
+ url: `http://127.0.0.1:${random}/c/shoes`,
+ env: {
+ PORT: String(random),
+ WEFT_SHOP_LATENCY: 'random',
+ WEFT_SHOP_SEED: '7',
+ WEFT_SHOP_FAIL_RATE: '0.3',
+ },
+ reuseExistingServer: false,
+ },
+ ],
+});
diff --git a/examples/weft_shop/e2e/random.e2e.ts b/examples/weft_shop/e2e/random.e2e.ts
new file mode 100644
index 0000000..40914da
--- /dev/null
+++ b/examples/weft_shop/e2e/random.e2e.ts
@@ -0,0 +1,124 @@
+// The build as `vp run start` serves it: every service waits a random time (seeded, so a run can be
+// repeated), and the recommendations and questions fail now and then. These tests say only what
+// must happen eventually, whatever order things arrive in: every page streams in completely, with
+// one copy of everything and nothing in the console, and a shopper can buy something, retrying
+// whatever fails.
+
+import {expect, test, type Page} from '@playwright/test';
+
+/** What the console says that it shouldn't: errors and warnings. */
+let complaints: string[] = [];
+
+test.beforeEach(({page}) => {
+ complaints = [];
+ page.on('console', (message) => {
+ if (message.type() === 'error' || message.type() === 'warning') {
+ complaints.push(message.text());
+ }
+ });
+ page.on('pageerror', (error) => complaints.push(String(error)));
+});
+
+test.afterEach(() => {
+ expect(complaints).toEqual([]);
+});
+
+/** Waits until nothing on the page is still loading (no fallback, skeleton or pending stock). */
+const streamedIn = (page: Page) =>
+ expect(page.locator('.loading, .skeleton, .stock.loading')).toHaveCount(0, {
+ timeout: 15_000,
+ });
+
+/** Clicks Retry on every block that failed, until none has. */
+const retryFailures = async (page: Page) => {
+ for (let tries = 0; tries < 20; tries++) {
+ const failed = page.locator('.block-error button');
+ if ((await failed.count()) === 0) return;
+ await failed.first().click();
+ await page.waitForTimeout(300);
+ await streamedIn(page);
+ }
+ throw new Error('a block kept failing');
+};
+
+for (const url of [
+ '/',
+ '/c/shoes',
+ '/c/home?sort=price-asc',
+ '/search?q=wool',
+ '/p/down-parka',
+ '/cart',
+]) {
+ test(`${url} streams in completely, once, and hydrates`, async ({page}) => {
+ await page.goto(url, {waitUntil: 'commit'});
+ await streamedIn(page);
+ await retryFailures(page);
+ await expect(page.locator('#main')).toHaveCount(1);
+ await expect(page.locator('#header')).toHaveCount(1);
+ // Hydrated: the header's search box answers.
+ await page.waitForFunction(
+ () => '$$resFiberProps' in document.querySelector('#search-input')!,
+ );
+ });
+}
+
+test('navigating quickly through pages still streaming lands on the last, complete', async ({
+ page,
+}) => {
+ await page.goto('/');
+ await page.waitForFunction(
+ () => '$$resFiberProps' in document.querySelector('#nav-shoes')!,
+ );
+ // Each click before the page before has finished streaming.
+ await page.locator('#nav-shoes').click();
+ await page.locator('#nav-outerwear').click();
+ await page.locator('#nav-bags').click();
+ await expect(page).toHaveURL('/c/bags');
+ await expect(page.locator('#hero-bags-hero h1')).toHaveText('Bags');
+ await streamedIn(page);
+ await expect(page.locator('#grid .card-name').first()).toHaveText(
+ 'Weekender',
+ );
+ await expect(page.locator('#blocks-c-bags')).toHaveCount(1);
+});
+
+test('a shopper can find something, buy it, and see the order, whatever fails along the way', async ({
+ page,
+}) => {
+ await page.goto('/');
+ await page.waitForFunction(
+ () => '$$resFiberProps' in document.querySelector('#search-input')!,
+ );
+ await page.locator('#search-input').fill('blanket');
+ await page.locator('#suggestion-wool-blanket').click();
+ await expect(page.locator('#product-name')).toHaveText('Wool Blanket');
+ await streamedIn(page);
+ await retryFailures(page);
+ await page.waitForFunction(
+ () => '$$resFiberProps' in document.querySelector('#variant-p26-lg')!,
+ );
+ await page.locator('#variant-p26-lg').click();
+ await expect(page.locator('#stock')).toContainText('Large');
+ await expect(page.locator('#offer-price')).toHaveText('$159.00');
+ await page.locator('#add').click();
+ await expect(page.locator('#cart-button')).toHaveText('Cart (1)');
+ await page.locator('#cart-button').click();
+ await page.locator('#drawer-cart-link').click();
+ await expect(page.locator('#summary-total')).toHaveText('$159.00', {
+ timeout: 15_000,
+ });
+ await page.locator('#checkout-link').click();
+ await page.locator('input[name=name]').fill('Grace');
+ await page.locator('input[name=street]').fill('2 Loop Lane');
+ await page.locator('input[name=city]').fill('Testville');
+ await page.locator('input[name=postcode]').fill('54321');
+ await page.locator('#checkout-next').click();
+ await page.locator('#checkout-next').click();
+ await page.locator('#checkout-next').click();
+ await expect(page.locator('#order-title')).toContainText('Order o');
+ await expect(page.locator('#cart-button')).toHaveText('Cart (0)');
+ // The order moves on by itself (by the clock, here), and the page follows.
+ await expect(page.locator('#order-status')).toHaveText('Status: Packed', {
+ timeout: 15_000,
+ });
+});
diff --git a/examples/weft_shop/e2e/shop.e2e.ts b/examples/weft_shop/e2e/shop.e2e.ts
new file mode 100644
index 0000000..2d4e3f0
--- /dev/null
+++ b/examples/weft_shop/e2e/shop.e2e.ts
@@ -0,0 +1,342 @@
+// What only a browser can show, against the build, with the latency gated (the tests open the
+// gates through the server's test-only routes): real HTML streaming and parsing, real chunk
+// loading, portals, focus, cookies, the history, and the page with JavaScript off. Everything that
+// can be checked without a browser is, in `tests/` (through the plugin's harness).
+
+import {
+ expect,
+ test,
+ type APIRequestContext,
+ type Page,
+} from '@playwright/test';
+
+const control = (request: APIRequestContext, ...path: string[]) =>
+ request.post(`/test/${path.map(encodeURIComponent).join('/')}`);
+const release = (request: APIRequestContext, gate: string) =>
+ control(request, 'release', gate);
+const releaseAll = (request: APIRequestContext) =>
+ control(request, 'release-all');
+
+/**
+ * Waits until the bootstrap has hydrated the element `selector` matches (it has its props): a
+ * click before then does nothing.
+ */
+const hydrated = (page: Page, selector: string) =>
+ page.waitForFunction((selector) => {
+ const element = document.querySelector(selector);
+ return element !== null && '$$resFiberProps' in element;
+ }, selector);
+
+/** Holds the requests for the chunk whose file name starts with `name` until the function it returns is called. */
+const holdChunk = async (page: Page, name: string) => {
+ let releaseChunk!: () => void;
+ const held = new Promise((resolve) => {
+ releaseChunk = resolve;
+ });
+ await page.route(
+ new RegExp(`/assets/${name}\\.res-[^/]*\\.js$`),
+ async (route) => {
+ await held;
+ await route.continue();
+ },
+ );
+ return releaseChunk;
+};
+
+/** What the console says that it shouldn't: errors and warnings. */
+let complaints: string[] = [];
+
+test.beforeEach(async ({page, request}) => {
+ // Every gate closed, and the store as it started.
+ await control(request, 'reset');
+ complaints = [];
+ page.on('console', (message) => {
+ if (message.type() === 'error' || message.type() === 'warning') {
+ complaints.push(message.text());
+ }
+ });
+ page.on('pageerror', (error) => complaints.push(String(error)));
+});
+
+test.afterEach(() => {
+ expect(complaints).toEqual([]);
+});
+
+test('the home page streams: the shell, then the layout, then each block as its service answers', async ({
+ page,
+ request,
+}) => {
+ // Not waiting for `load`: the page is still streaming, so it hasn't loaded.
+ await page.goto('/', {waitUntil: 'commit'});
+ await expect(page.locator('#home-loading')).toBeVisible();
+ await expect(page.locator('#cart-button')).toHaveText('Cart (0)');
+ await release(request, 'layout:home');
+ await expect(page.locator('#loading-featured')).toBeVisible();
+ await expect(page.locator('#loading-hero')).toBeVisible();
+ await release(request, 'rail:featured');
+ await expect(page.locator('#carousel-featured')).toBeVisible();
+ await expect(page.locator('#stock-loading-p1')).toHaveText('Checking stock…');
+ await expect(page.locator('#loading-hero')).toBeVisible();
+ await release(request, 'stock:p1');
+ await expect(page.locator('#stock-p1')).toHaveText('In stock');
+ await release(request, 'block:hero');
+ await expect(page.locator('#hero-hero h1')).toHaveText('Made to last');
+ await releaseAll(request);
+ await expect(page.locator('#newsletter')).toBeVisible();
+ // One copy of everything, hydrated.
+ await hydrated(page, '#next-featured');
+ await expect(page.locator('#blocks-home')).toHaveCount(1);
+ await page.locator('#next-featured').click();
+ await expect(page.locator('#position-featured')).toHaveText('5–6 of 6');
+});
+
+test("blocks arriving while an entrypoint's chunk is still loading are hydrated once it has, not rendered twice", async ({
+ page,
+ request,
+}) => {
+ const releaseTabs = await holdChunk(page, 'Tabs');
+ await page.goto('/', {waitUntil: 'commit'});
+ await hydrated(page, '#cart-button');
+ await releaseAll(request);
+ await expect(page.locator('#newsletter')).toBeVisible();
+ await expect(page.locator('#blocks-home')).toHaveCount(1);
+ releaseTabs();
+ await hydrated(page, '#tabs-tabs-tab-1');
+ await page.locator('#tabs-tabs-tab-1').click();
+ await expect(page.locator('#tabs-tabs-panel-1')).toBeVisible();
+ await expect(page.locator('#tabs-tabs-panel-0')).toBeHidden();
+ await expect(page.locator('#blocks-home')).toHaveCount(1);
+ await expect(page.locator('#tabs-tabs')).toHaveCount(1);
+});
+
+test('shopping, start to finish: a filter, a product, a variant, the cart, a promo code, checkout and the order', async ({
+ page,
+ request,
+}) => {
+ await releaseAll(request);
+ await control(request, 'poll', '200');
+ await page.goto('/c/shoes');
+ await hydrated(page, '#color-green');
+ await page.locator('#color-green').check();
+ await expect(page).toHaveURL('/c/shoes?color=green');
+ await expect(page.locator('#result-count')).toHaveText('Showing 2 of 2');
+ await page.locator('#card-p8 .card-link').click();
+ await expect(page.locator('#product-name')).toHaveText('Hiking Boot');
+ await expect(page).toHaveURL('/p/hiking-boot');
+ await hydrated(page, '#variant-p8-9');
+ await page.locator('#variant-p8-9').click();
+ await expect(page.locator('#stock')).toHaveText('Size 9: 5 in stock');
+ await page.locator('#add-to-cart select').selectOption('2');
+ await page.locator('#add').click();
+ await expect(page.locator('#cart-button')).toHaveText('Cart (2)');
+ await expect(page.locator('#toasts')).toContainText(
+ 'Added 2 × Hiking Boot to your cart',
+ );
+ // The drawer, in a portal at the end of the body.
+ await page.locator('#cart-button').click();
+ await expect(page.locator('body > #cart-drawer')).toContainText(
+ 'Hiking Boot (Size 9) × 2: $338.00',
+ );
+ await page.locator('#drawer-cart-link').click();
+ await expect(page.locator('#cart-title')).toBeVisible();
+ await expect(page.locator('#cart-drawer')).toHaveCount(0);
+ await page.locator('.promo-code').fill('loom20');
+ await page.locator('#apply-promo').click();
+ await expect(page.locator('#summary-discount')).toHaveText('-$67.60');
+ await expect(page.locator('#summary-total')).toHaveText('$270.40');
+ await page.locator('#checkout-link').click();
+ await page.locator('input[name=name]').fill('Ada');
+ await page.locator('input[name=street]').fill('1 Loop Lane');
+ await page.locator('input[name=city]').fill('Testville');
+ await page.locator('input[name=postcode]').fill('12345');
+ await page.locator('#checkout-next').click();
+ await page.locator('#delivery-express').check();
+ await page.locator('#checkout-next').click();
+ await expect(page.locator('#review-total')).toHaveText('Total: $285.40');
+ await page.locator('#checkout-next').click();
+ await expect(page).toHaveURL('/orders/o1001');
+ await expect(page.locator('#order-status')).toHaveText('Status: Placed');
+ await expect(page.locator('#cart-button')).toHaveText('Cart (0)');
+ // The order page refreshes itself: the server's newer status comes in.
+ await control(request, 'advance', 'o1001');
+ await expect(page.locator('#order-status')).toHaveText('Status: Packed');
+ await control(request, 'advance', 'o1001');
+ await control(request, 'advance', 'o1001');
+ await expect(page.locator('#order-status')).toHaveText('Status: Delivered');
+ await expect(page.locator('#estimate')).toHaveText('Delivered.');
+});
+
+test('the sign-in dialog takes focus, closes on Escape, and signing in personalises the home page', async ({
+ page,
+ request,
+}) => {
+ await releaseAll(request);
+ await page.goto('/');
+ await hydrated(page, '#sign-in');
+ await page.locator('#sign-in').click();
+ await expect(
+ page.locator('body > #signin-overlay #signin-dialog'),
+ ).toBeVisible();
+ await expect(page.locator('#signin-dialog .signin-name')).toBeFocused();
+ await page.keyboard.press('Escape');
+ await expect(page.locator('#signin-dialog')).toHaveCount(0);
+ await page.locator('#sign-in').click();
+ await page.locator('#signin-dialog .signin-name').fill('Ada');
+ await page.keyboard.press('Enter');
+ await expect(page.locator('#account-menu')).toContainText('Hi, Ada');
+ await expect(page.locator('#hero-welcome h1')).toHaveText(
+ 'Welcome back, Ada',
+ );
+ await expect(page.locator('#carousel-for-you')).toBeVisible();
+ // The session is the cookie's: a new page load is still Ada's.
+ await page.reload();
+ await expect(page.locator('#hero-welcome h1')).toHaveText(
+ 'Welcome back, Ada',
+ );
+ expect((await page.context().cookies()).map((cookie) => cookie.name)).toEqual(
+ ['sid'],
+ );
+});
+
+test('a link to a page that redirects lands at the URL the redirect leads to, in place', async ({
+ page,
+ request,
+}) => {
+ await releaseAll(request);
+ const where = () => {
+ const url = new URL(page.url());
+ return url.pathname + url.search;
+ };
+ await page.goto('/');
+ await hydrated(page, '#sign-in');
+ await page.locator('#sign-in').click();
+ await page.locator('#signin-dialog .signin-name').fill('Ada');
+ await page.keyboard.press('Enter');
+ await hydrated(page, '#account-link');
+ // Signed out behind the page's back: its account link now redirects to sign in.
+ await page.context().clearCookies();
+ // Gone if the page loads again: the link should navigate in place.
+ await page.evaluate(() => {
+ (window as {stayed?: boolean}).stayed = true;
+ });
+ await page.locator('#account-link').click();
+ await expect(page.locator('#signin-page')).toBeVisible();
+ expect(where()).toBe('/signin?next=/account');
+ // The history's entry is the one it led to: back is the page before, forward that one again.
+ await page.goBack();
+ await expect(page.locator('#signin-page')).toHaveCount(0);
+ expect(where()).toBe('/');
+ await page.goForward();
+ await expect(page.locator('#signin-page')).toBeVisible();
+ expect(where()).toBe('/signin?next=/account');
+ // Signing in there goes on to the account.
+ await hydrated(page, '#signin-page .signin-name');
+ await page.locator('#signin-page .signin-name').fill('Grace');
+ await page.keyboard.press('Enter');
+ await expect(page.locator('#account-title')).toHaveText("Grace's account");
+ expect(where()).toBe('/account');
+ // With nothing in the cart, checking out redirects back to it.
+ await page.locator('#cart-button').click();
+ await page.locator('#drawer-cart-link').click();
+ await hydrated(page, '#checkout-link');
+ await page.locator('#checkout-link').click();
+ await expect(page.locator('#cart-empty')).toBeVisible();
+ await expect(page.locator('#checkout-title')).toHaveCount(0);
+ expect(where()).toBe('/cart');
+ expect(await page.evaluate(() => (window as {stayed?: boolean}).stayed)).toBe(
+ true,
+ );
+});
+
+test('the currency picked changes every price at once, and is the session’s for the next page load', async ({
+ page,
+ request,
+}) => {
+ await releaseAll(request);
+ await page.goto('/c/outerwear');
+ await hydrated(page, '#currency');
+ await expect(page.locator('#card-p10 .price')).toHaveText('$149.00');
+ // On sale: the price it was, too.
+ await expect(page.locator('#card-p9 .price')).toHaveText('$373.75 $299.00');
+ const saved = page.waitForResponse((response) =>
+ response.url().endsWith('/_weft/rpc/prefs.setCurrency'),
+ );
+ await page.locator('#currency').selectOption('EUR');
+ await expect(page.locator('#card-p10 .price')).toHaveText('€137.08');
+ await expect(page.locator('#card-p9 .price')).toHaveText('€343.85 €275.08');
+ await saved;
+ await page.reload();
+ await expect(page.locator('#card-p10 .price')).toHaveText('€137.08');
+ await expect(page.locator('#currency')).toHaveValue('EUR');
+});
+
+test('the back and forward buttons go through filtered pages, and the filters follow', async ({
+ page,
+ request,
+}) => {
+ await releaseAll(request);
+ await page.goto('/c/bags');
+ await hydrated(page, '#color-black');
+ await page.locator('#color-black').check();
+ await expect(page).toHaveURL('/c/bags?color=black');
+ await expect(page.locator('#result-count')).toHaveText('Showing 2 of 2');
+ await page.locator('#sort').selectOption('price-desc');
+ await expect(page).toHaveURL('/c/bags?color=black&sort=price-desc');
+ await page.goBack();
+ await expect(page).toHaveURL('/c/bags?color=black');
+ await expect(page.locator('#sort')).toHaveValue('featured');
+ await page.goBack();
+ await expect(page).toHaveURL('/c/bags');
+ await expect(page.locator('#result-count')).toHaveText('Showing 4 of 8');
+ await expect(page.locator('#color-black')).not.toBeChecked();
+ await page.goForward();
+ await expect(page.locator('#color-black')).toBeChecked();
+ await expect(page.locator('#result-count')).toHaveText('Showing 2 of 2');
+});
+
+test('a block whose service fails says so, and Retry fetches it by itself once it’s back', async ({
+ page,
+ request,
+}) => {
+ await control(request, 'fail', 'rail:featured');
+ await releaseAll(request);
+ await page.goto('/');
+ // The build sends no error messages: only that the server failed.
+ await expect(page.locator('#block-error-featured')).toContainText(
+ "Couldn't load this: WeftServer: the server failed to render this",
+ );
+ await expect(page.locator('#hero-hero')).toBeVisible();
+ await control(request, 'heal', 'rail:featured');
+ await release(request, 'rail:featured');
+ await hydrated(page, '#retry-featured');
+ const refetched = page.waitForRequest((sent) =>
+ sent.url().endsWith('/blocks/home/featured'),
+ );
+ await page.locator('#retry-featured').click();
+ expect((await refetched).headers()['accept']).toBe('text/x-weft-payload');
+ await expect(page.locator('#carousel-featured')).toBeVisible();
+ await expect(page.locator('#block-error-featured')).toHaveCount(0);
+});
+
+test.describe('with JavaScript off', () => {
+ test.use({javaScriptEnabled: false});
+
+ test('the pages are readable: everything the server rendered', async ({
+ page,
+ request,
+ }) => {
+ await releaseAll(request);
+ await page.goto('/');
+ await expect(page.locator('#hero-hero h1')).toHaveText('Made to last');
+ await expect(
+ page.locator('#carousel-featured #card-p1 .card-name'),
+ ).toHaveText('Trail Runner');
+ await expect(page.locator('#carousel-featured #card-p1 .price')).toHaveText(
+ '$129.00',
+ );
+ await page.goto('/p/trail-runner');
+ await expect(page.locator('#product-name')).toHaveText('Trail Runner');
+ await expect(page.locator('#stock')).toHaveText('Size 7: only 1 left');
+ await expect(page.locator('#card-p2')).toBeVisible();
+ });
+});
diff --git a/examples/weft_shop/package.json b/examples/weft_shop/package.json
new file mode 100644
index 0000000..f335c1f
--- /dev/null
+++ b/examples/weft_shop/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@e-cline/weft_shop_example",
+ "version": "0.0.0",
+ "private": true,
+ "description": "A storefront built with Weft: server-driven, streamed home and catalog pages, a cart, checkout and accounts, and a test bed for how Weft's features work together.",
+ "type": "module",
+ "dependencies": {
+ "@e-cline/servy": "workspace:*",
+ "@e-cline/signals": "workspace:*",
+ "@e-cline/weft": "workspace:*",
+ "@e-cline/weft_dom": "workspace:*",
+ "@e-cline/weft_server": "workspace:*",
+ "@e-cline/weft_signals": "workspace:*",
+ "@rescript/runtime": "catalog:",
+ "@rescript/webapi": "catalog:"
+ },
+ "devDependencies": {
+ "@e-cline/ppx_weft": "workspace:*",
+ "@e-cline/weft_vite_plugin": "workspace:*",
+ "@playwright/test": "catalog:",
+ "@types/node": "catalog:",
+ "jsdom": "catalog:",
+ "rescript": "catalog:",
+ "rescript-vitest": "catalog:",
+ "vite": "catalog:",
+ "vite-plus": "catalog:"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+}
diff --git a/examples/weft_shop/rescript.json b/examples/weft_shop/rescript.json
new file mode 100644
index 0000000..3b19dd4
--- /dev/null
+++ b/examples/weft_shop/rescript.json
@@ -0,0 +1,26 @@
+{
+ "name": "@e-cline/weft_shop_example",
+ "sources": [
+ {"dir": "src", "subdirs": true},
+ {"dir": "tests", "subdirs": true, "type": "dev"}
+ ],
+ "package-specs": [
+ {
+ "module": "esmodule",
+ "in-source": true
+ }
+ ],
+ "suffix": ".res.js",
+ "namespace": "WeftShop",
+ "dev-dependencies": ["rescript-vitest", "@e-cline/weft_vite_plugin"],
+ "dependencies": [
+ "@e-cline/servy",
+ "@e-cline/signals",
+ "@e-cline/weft",
+ "@e-cline/weft_dom",
+ "@e-cline/weft_server",
+ "@e-cline/weft_signals",
+ "@rescript/webapi"
+ ],
+ "ppx-flags": ["@e-cline/ppx_weft/ppx"]
+}
diff --git a/examples/weft_shop/serve.mjs b/examples/weft_shop/serve.mjs
new file mode 100644
index 0000000..3377d5e
--- /dev/null
+++ b/examples/weft_shop/serve.mjs
@@ -0,0 +1,13 @@
+// Serves the build (`vp run build` first): the client's files from `dist/client`, and every other
+// request answered by the server's handler. Run with `vp run start`.
+import {serve} from '@e-cline/weft_vite_plugin/node';
+
+const port = Number(process.env.PORT ?? 3000);
+// This machine only, unless told otherwise.
+const hostname = process.env.HOST ?? '127.0.0.1';
+
+const server = await serve({dir: 'dist', port, hostname});
+const address = server.address();
+console.log(
+ `Listening on http://${hostname}:${typeof address === 'object' ? address.port : port}`,
+);
diff --git a/examples/weft_shop/src/client/AccountMenu.res b/examples/weft_shop/src/client/AccountMenu.res
new file mode 100644
index 0000000..f9915a6
--- /dev/null
+++ b/examples/weft_shop/src/client/AccountMenu.res
@@ -0,0 +1,35 @@
+/*** Who's signed in, and signing in (the dialog) or out (the `auth.signOut` RPC, then a refresh). */
+
+@weft.component({kind: Entrypoint})
+let make = () => {
+ let {user} = ShopContext.use()
+ let navigation = WeftServer.Navigation.useNavigation()
+ let signOut = WeftServer.Rpc.use(Api.signOut)
+
+ }
+}
+
+@weft.component({kind: Entrypoint})
+let make = (~initial: string) => {
+ let (text, setText) = Weft.Hooks.useState(() => initial)
+ let (showing, setShowing) = Weft.Hooks.useState(() => false)
+ let deferred = Weft.Hooks.useDeferredValue(text)
+ let navigation = WeftServer.Navigation.useNavigation()
+ let listId = Weft.Hooks.useId()
+
+}
diff --git a/examples/weft_shop/src/client/SearchBox.res.js b/examples/weft_shop/src/client/SearchBox.res.js
new file mode 100644
index 0000000..d73ba42
--- /dev/null
+++ b/examples/weft_shop/src/client/SearchBox.res.js
@@ -0,0 +1,139 @@
+'use weft entrypoint'
+// Generated by ReScript, PLEASE EDIT WITH CARE
+
+import * as Hooks$Weft from "@e-cline/weft/src/shared/Hooks.res.js";
+import * as Dom$WeftDom from "@e-cline/weft_dom/src/shared/Dom.res.js";
+import * as Jsx$WeftDom from "@e-cline/weft_dom/src/shared/Jsx.res.js";
+import * as Api$WeftShop from "../shared/Api.res.js";
+import * as Link$WeftShop from "./Link.res.js";
+import * as Rpc$WeftServer from "@e-cline/weft_server/src/shared/Rpc.res.js";
+import * as Entrypoint$Weft from "@e-cline/weft/src/shared/Entrypoint.res.js";
+import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
+import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
+import * as Navigation$WeftServer from "@e-cline/weft_server/src/shared/Navigation.res.js";
+
+function SearchBox$Suggestions(props) {
+ let onPick = props.onPick;
+ let query = props.query;
+ let navigation = Navigation$WeftServer.useNavigation();
+ let match = Hooks$Weft.useState(() => [
+ query,
+ []
+ ]);
+ let setFound = match[1];
+ let found = match[0];
+ Hooks$Weft.useEffect1(() => {
+ let query$1 = query.trim();
+ if (query$1.length < 2) {
+ setFound(param => [
+ query$1,
+ []
+ ]);
+ return;
+ }
+ let controller = new AbortController();
+ (async () => {
+ let suggestions = await Rpc$WeftServer.call(Api$WeftShop.suggest, Navigation$WeftServer.client(navigation), query$1, undefined, Primitive_option.some(controller.signal));
+ if (suggestions.TAG !== "Ok") {
+ return;
+ }
+ let suggestions$1 = suggestions._0;
+ return setFound(param => [
+ query$1,
+ suggestions$1
+ ]);
+ })();
+ return () => {
+ controller.abort();
+ };
+ }, [query]);
+ let suggestions = found[1];
+ if (Primitive_object.equal(suggestions, []) || found[0] !== query.trim()) {
+ return Jsx$WeftDom.$$null;
+ } else {
+ return Jsx$WeftDom.Elements.jsx("ul", {
+ id: props.listId,
+ className: "suggestions",
+ role: "listbox",
+ children: suggestions.map(suggestion => Jsx$WeftDom.Elements.jsxsKeyed("li", {
+ role: "option",
+ onClick: param => onPick(),
+ children: [
+ Jsx$WeftDom.jsx(Link$WeftShop.make, {
+ href: `/p/` + suggestion.slug,
+ id: `suggestion-` + suggestion.slug,
+ children: suggestion.name
+ }),
+ Jsx$WeftDom.Elements.jsx("span", {
+ className: "suggestion-category",
+ children: suggestion.category
+ })
+ ]
+ }, suggestion.slug, undefined))
+ });
+ }
+}
+
+let Suggestions = {
+ make: SearchBox$Suggestions
+};
+
+function SearchBox(props) {
+ let initial = props.initial;
+ let match = Hooks$Weft.useState(() => initial);
+ let setText = match[1];
+ let text = match[0];
+ let match$1 = Hooks$Weft.useState(() => false);
+ let setShowing = match$1[1];
+ let showing = match$1[0];
+ let deferred = Hooks$Weft.useDeferredValue(text);
+ let navigation = Navigation$WeftServer.useNavigation();
+ let listId = Hooks$Weft.useId();
+ return Jsx$WeftDom.Elements.jsxs("form", {
+ id: "search",
+ role: "search",
+ onSubmit: event => {
+ event.preventDefault();
+ setShowing(param => false);
+ Navigation$WeftServer.navigate(navigation, `/search?q=` + encodeURIComponent(text.trim()));
+ },
+ children: [
+ Jsx$WeftDom.Elements.jsx("input", {
+ id: "search-input",
+ role: "combobox",
+ "aria-label": "Search",
+ "aria-expanded": showing ? "true" : "false",
+ "aria-controls": listId,
+ type: "search",
+ value: text,
+ placeholder: "Search the store",
+ autoComplete: "off",
+ onInput: event => {
+ let value = Dom$WeftDom.Event.targetValue(event);
+ setText(param => value);
+ setShowing(param => true);
+ }
+ }),
+ Jsx$WeftDom.Elements.jsx("button", {
+ id: "search-submit",
+ type: "submit",
+ children: "Search"
+ }),
+ showing ? Jsx$WeftDom.jsx(SearchBox$Suggestions, {
+ query: deferred,
+ listId: listId,
+ onPick: () => setShowing(param => false)
+ }) : Jsx$WeftDom.$$null
+ ]
+ });
+}
+
+Entrypoint$Weft.mark(SearchBox, "@e-cline/weft_shop_example/src/client/SearchBox.res#");
+
+let make = SearchBox;
+
+export {
+ Suggestions,
+ make,
+}
+/* Not a pure module */
diff --git a/examples/weft_shop/src/client/ShopContext.res b/examples/weft_shop/src/client/ShopContext.res
new file mode 100644
index 0000000..de81d61
--- /dev/null
+++ b/examples/weft_shop/src/client/ShopContext.res
@@ -0,0 +1,30 @@
+/***
+What every part of the page shares, from `ShopShell`: the currency prices are shown in (and how to
+change it), who's signed in, and the cart, the newest of the server's and the client's own (from an
+RPC's answer).
+
+A plain client module, imported by several entrypoints: each chunk gets the same module (Vite puts
+it in a chunk they share), so they find the one context.
+*/
+
+open Types
+
+type value = {
+ currency: currency,
+ setCurrency: currency => unit,
+ user: option,
+ cart: cart,
+}
+
+let context = Weft.Context.make({
+ currency: USD,
+ setCurrency: _ => (),
+ user: None,
+ cart: emptyCart,
+})
+
+module Provider = {
+ let make = Weft.Context.provider(context)
+}
+
+let use = () => Weft.Hooks.useContext(context)
diff --git a/examples/weft_shop/src/client/ShopContext.res.js b/examples/weft_shop/src/client/ShopContext.res.js
new file mode 100644
index 0000000..3b5163c
--- /dev/null
+++ b/examples/weft_shop/src/client/ShopContext.res.js
@@ -0,0 +1,29 @@
+// Generated by ReScript, PLEASE EDIT WITH CARE
+
+import * as Hooks$Weft from "@e-cline/weft/src/shared/Hooks.res.js";
+import * as Context$Weft from "@e-cline/weft/src/shared/Context.res.js";
+import * as Types$WeftShop from "../shared/Types.res.js";
+
+let context = Context$Weft.make({
+ currency: "USD",
+ setCurrency: param => {},
+ user: undefined,
+ cart: Types$WeftShop.emptyCart
+});
+
+let make = Context$Weft.provider(context);
+
+let Provider = {
+ make: make
+};
+
+function use() {
+ return Hooks$Weft.useContext(context);
+}
+
+export {
+ context,
+ Provider,
+ use,
+}
+/* context Not a pure module */
diff --git a/examples/weft_shop/src/client/ShopShell.res b/examples/weft_shop/src/client/ShopShell.res
new file mode 100644
index 0000000..611254f
--- /dev/null
+++ b/examples/weft_shop/src/client/ShopShell.res
@@ -0,0 +1,49 @@
+/***
+The page around every page: the shared context (`ShopContext`) for what's below, the toasts, the
+sign-in dialog and the offline banner. It stays in place as the shopper navigates, so the currency
+they picked here, and the toasts, carry over.
+
+The server gives it the session's currency, user and cart with every page (a navigation or a refresh
+brings new ones); the cart an RPC answered with since (in `Store`) wins over them until a newer one
+comes from the server.
+*/
+
+open Types
+
+@weft.component({kind: Entrypoint})
+let make = (
+ ~currency as serverCurrency: currency,
+ ~user: option,
+ ~cart as serverCart: cart,
+ ~children: Weft.Element.t,
+) => {
+ let (currency, setCurrency) = Weft.Hooks.useState(() => serverCurrency)
+ // The server's currency, when it changes (picked in another tab, say), wins.
+ Weft.Hooks.useEffect1(() => {
+ setCurrency(_ => serverCurrency)
+ None
+ }, [serverCurrency])
+ // Which shell this is, so it doesn't believe a cart a page before it left in the store.
+ let owner = Weft.Hooks.useRef(Math.random())
+ let stored = WeftSignals.SignalHooks.useSignal(Signals.Atom.toSignal(Store.cart))
+ Weft.Hooks.useEffect1(() => {
+ Store.offerCart(~owner=owner.current, serverCart)
+ None
+ }, [serverCart.version])
+ let cart = switch stored {
+ | Some((found, cart)) if found == owner.current && cart.version >= serverCart.version => cart
+ | _ => serverCart
+ }
+ let value: ShopContext.value = {
+ currency,
+ setCurrency: next => setCurrency(_ => next),
+ user,
+ cart,
+ }
+
+
+ children
+
+
+
+}
diff --git a/examples/weft_shop/src/client/ShopShell.res.js b/examples/weft_shop/src/client/ShopShell.res.js
new file mode 100644
index 0000000..0e822fd
--- /dev/null
+++ b/examples/weft_shop/src/client/ShopShell.res.js
@@ -0,0 +1,61 @@
+'use weft entrypoint'
+// Generated by ReScript, PLEASE EDIT WITH CARE
+
+import * as Hooks$Weft from "@e-cline/weft/src/shared/Hooks.res.js";
+import * as Jsx$WeftDom from "@e-cline/weft_dom/src/shared/Jsx.res.js";
+import * as Store$WeftShop from "./Store.res.js";
+import * as Entrypoint$Weft from "@e-cline/weft/src/shared/Entrypoint.res.js";
+import * as Toaster$WeftShop from "./Toaster.res.js";
+import * as ShopContext$WeftShop from "./ShopContext.res.js";
+import * as OnlineBanner$WeftShop from "./OnlineBanner.res.js";
+import * as SignInDialog$WeftShop from "./SignInDialog.res.js";
+import * as SignalHooks$WeftSignals from "@e-cline/weft_signals/src/SignalHooks.res.js";
+
+function ShopShell(props) {
+ let serverCart = props.cart;
+ let serverCurrency = props.currency;
+ let match = Hooks$Weft.useState(() => serverCurrency);
+ let setCurrency = match[1];
+ Hooks$Weft.useEffect1(() => {
+ setCurrency(param => serverCurrency);
+ }, [serverCurrency]);
+ let owner = Hooks$Weft.useRef(Math.random());
+ let stored = SignalHooks$WeftSignals.useSignal(Store$WeftShop.cart);
+ Hooks$Weft.useEffect1(() => {
+ Store$WeftShop.offerCart(owner.current, serverCart);
+ }, [serverCart.version]);
+ let cart;
+ if (stored !== undefined) {
+ let cart$1 = stored[1];
+ cart = stored[0] === owner.current && cart$1.version >= serverCart.version ? cart$1 : serverCart;
+ } else {
+ cart = serverCart;
+ }
+ let value_currency = match[0];
+ let value_setCurrency = next => setCurrency(param => next);
+ let value_user = props.user;
+ let value = {
+ currency: value_currency,
+ setCurrency: value_setCurrency,
+ user: value_user,
+ cart: cart
+ };
+ return Jsx$WeftDom.jsxs(ShopContext$WeftShop.Provider.make, {
+ value: value,
+ children: [
+ Jsx$WeftDom.jsx(OnlineBanner$WeftShop.make, {}),
+ props.children,
+ Jsx$WeftDom.jsx(Toaster$WeftShop.make, {}),
+ Jsx$WeftDom.jsx(SignInDialog$WeftShop.make, {})
+ ]
+ });
+}
+
+Entrypoint$Weft.mark(ShopShell, "@e-cline/weft_shop_example/src/client/ShopShell.res#");
+
+let make = ShopShell;
+
+export {
+ make,
+}
+/* Not a pure module */
diff --git a/examples/weft_shop/src/client/SignInDialog.res b/examples/weft_shop/src/client/SignInDialog.res
new file mode 100644
index 0000000..cf61542
--- /dev/null
+++ b/examples/weft_shop/src/client/SignInDialog.res
@@ -0,0 +1,50 @@
+/***
+The sign-in dialog, opened from anywhere (`Store.openSignIn`): rendered into a layer at the end of
+`` (a portal), while it stays in the shell's place in the tree, so it has the page's context
+(the navigation it refreshes, the RPC client). Its name field takes the focus as it opens; Escape,
+or a click outside, closes it.
+
+Only ever open in the browser: the server renders it closed, so it never touches `document` there.
+*/
+
+@weft.component
+let make = () => {
+ let isOpen = WeftSignals.SignalHooks.useSignal(Signals.Atom.toSignal(Store.signInOpen))
+ let navigation = WeftServer.Navigation.useNavigation()
+ let titleId = Weft.Hooks.useId()
+ if isOpen {
+ WeftDom.Portal.create(
+
+ if WeftDom.Dom.Event.target(event) == WeftDom.Dom.Event.currentTarget(event) {
+ Store.closeSignIn()
+ }}
+ // On the overlay, not the document: a listener an effect adds would miss an Escape pressed
+ // between the commit (which focuses the field) and the effect.
+ onKeyDown={event =>
+ if WeftDom.Dom.Event.key(event) == "Escape" {
+ Store.closeSignIn()
+ }}
+ >
+
+
{WeftDom.Jsx.string("Sign in")}
+ {
+ Store.closeSignIn()
+ Store.toast(`Signed in as ${name}`)
+ navigation->WeftServer.Navigation.refresh->ignore
+ }}
+ />
+
+
+
,
+ ~container=WeftDom.Dom.document->WeftDom.Dom.body,
+ )
+ } else {
+ WeftDom.Jsx.null
+ }
+}
diff --git a/examples/weft_shop/src/client/SignInDialog.res.js b/examples/weft_shop/src/client/SignInDialog.res.js
new file mode 100644
index 0000000..dfda342
--- /dev/null
+++ b/examples/weft_shop/src/client/SignInDialog.res.js
@@ -0,0 +1,65 @@
+// Generated by ReScript, PLEASE EDIT WITH CARE
+
+import * as Hooks$Weft from "@e-cline/weft/src/shared/Hooks.res.js";
+import * as Jsx$WeftDom from "@e-cline/weft_dom/src/shared/Jsx.res.js";
+import * as Portal$WeftDom from "@e-cline/weft_dom/src/shared/Portal.res.js";
+import * as Store$WeftShop from "./Store.res.js";
+import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
+import * as Navigation$WeftServer from "@e-cline/weft_server/src/shared/Navigation.res.js";
+import * as SignInFields$WeftShop from "./SignInFields.res.js";
+import * as SignalHooks$WeftSignals from "@e-cline/weft_signals/src/SignalHooks.res.js";
+
+function SignInDialog(props) {
+ let isOpen = SignalHooks$WeftSignals.useSignal(Store$WeftShop.signInOpen);
+ let navigation = Navigation$WeftServer.useNavigation();
+ let titleId = Hooks$Weft.useId();
+ if (isOpen) {
+ return Portal$WeftDom.create(Jsx$WeftDom.Elements.jsx("div", {
+ id: "signin-overlay",
+ onClick: event => {
+ if (Primitive_object.equal(event.target, event.currentTarget)) {
+ return Store$WeftShop.closeSignIn();
+ }
+ },
+ onKeyDown: event => {
+ if (event.key === "Escape") {
+ return Store$WeftShop.closeSignIn();
+ }
+ },
+ children: Jsx$WeftDom.Elements.jsxs("div", {
+ id: "signin-dialog",
+ role: "dialog",
+ "aria-labelledby": titleId,
+ "aria-modal": "true",
+ children: [
+ Jsx$WeftDom.Elements.jsx("h2", {
+ id: titleId,
+ children: "Sign in"
+ }),
+ Jsx$WeftDom.jsx(SignInFields$WeftShop.make, {
+ onSignedIn: name => {
+ Store$WeftShop.closeSignIn();
+ Store$WeftShop.toast(`Signed in as ` + name);
+ Navigation$WeftServer.refresh(navigation);
+ },
+ autoFocus: true
+ }),
+ Jsx$WeftDom.Elements.jsx("button", {
+ id: "signin-close",
+ onClick: param => Store$WeftShop.closeSignIn(),
+ children: "Close"
+ })
+ ]
+ })
+ }), document.body, undefined);
+ } else {
+ return Jsx$WeftDom.$$null;
+ }
+}
+
+let make = SignInDialog;
+
+export {
+ make,
+}
+/* Hooks-Weft Not a pure module */
diff --git a/examples/weft_shop/src/client/SignInFields.res b/examples/weft_shop/src/client/SignInFields.res
new file mode 100644
index 0000000..6535d51
--- /dev/null
+++ b/examples/weft_shop/src/client/SignInFields.res
@@ -0,0 +1,48 @@
+/***
+Signing in (the `auth.signIn` RPC): a name, and no password (it's a demo). Used by the dialog and by
+the sign-in page. Its label is linked by an ID from `useId`.
+*/
+
+@weft.component
+let make = (~onSignedIn: string => unit, ~autoFocus=false) => {
+ let (name, setName) = Weft.Hooks.useState(() => "")
+ let (error, setError) = Weft.Hooks.useState(() => None)
+ let (busy, setBusy) = Weft.Hooks.useState(() => false)
+ let inputId = Weft.Hooks.useId()
+ let signIn = WeftServer.Rpc.use(Api.signIn)
+ let submit = event => {
+ WeftDom.Dom.Event.preventDefault(event)
+ setBusy(_ => true)
+
+ (
+ async () => {
+ let result = await signIn(name)
+ setBusy(_ => false)
+ switch result {
+ | Ok(name) =>
+ setError(_ => None)
+ onSignedIn(name)
+ | Error(error) => setError(_ => Some(Api.describe(error)))
+ }
+ }
+ )()->ignore
+ }
+
+}
diff --git a/examples/weft_shop/src/client/SignInFields.res.js b/examples/weft_shop/src/client/SignInFields.res.js
new file mode 100644
index 0000000..60c9105
--- /dev/null
+++ b/examples/weft_shop/src/client/SignInFields.res.js
@@ -0,0 +1,74 @@
+// Generated by ReScript, PLEASE EDIT WITH CARE
+
+import * as Hooks$Weft from "@e-cline/weft/src/shared/Hooks.res.js";
+import * as Dom$WeftDom from "@e-cline/weft_dom/src/shared/Dom.res.js";
+import * as Jsx$WeftDom from "@e-cline/weft_dom/src/shared/Jsx.res.js";
+import * as Api$WeftShop from "../shared/Api.res.js";
+import * as Rpc$WeftServer from "@e-cline/weft_server/src/shared/Rpc.res.js";
+
+function SignInFields(props) {
+ let __autoFocus = props.autoFocus;
+ let onSignedIn = props.onSignedIn;
+ let __autoFocus_value = __autoFocus !== undefined ? __autoFocus : false;
+ let match = Hooks$Weft.useState(() => "");
+ let setName = match[1];
+ let name = match[0];
+ let match$1 = Hooks$Weft.useState(() => {});
+ let setError = match$1[1];
+ let error = match$1[0];
+ let match$2 = Hooks$Weft.useState(() => false);
+ let setBusy = match$2[1];
+ let busy = match$2[0];
+ let inputId = Hooks$Weft.useId();
+ let signIn = Rpc$WeftServer.use(Api$WeftShop.signIn, undefined);
+ let submit = event => {
+ event.preventDefault();
+ setBusy(param => true);
+ (async () => {
+ let result = await signIn(name);
+ setBusy(param => false);
+ if (result.TAG === "Ok") {
+ setError(param => {});
+ return onSignedIn(result._0);
+ }
+ let error = result._0;
+ return setError(param => Api$WeftShop.describe(error));
+ })();
+ };
+ return Jsx$WeftDom.Elements.jsxs("form", {
+ className: "signin-form",
+ onSubmit: submit,
+ children: [
+ Jsx$WeftDom.Elements.jsx("label", {
+ htmlFor: inputId,
+ children: "Your name"
+ }),
+ Jsx$WeftDom.Elements.jsx("input", {
+ id: inputId,
+ className: "signin-name",
+ value: name,
+ autoFocus: __autoFocus_value,
+ autoComplete: "name",
+ onInput: event => setName(param => Dom$WeftDom.Event.targetValue(event))
+ }),
+ Jsx$WeftDom.Elements.jsx("button", {
+ className: "signin-submit",
+ type: "submit",
+ disabled: busy,
+ children: busy ? "Signing in…" : "Sign in"
+ }),
+ error !== undefined ? Jsx$WeftDom.Elements.jsx("p", {
+ className: "signin-error",
+ role: "alert",
+ children: error
+ }) : Jsx$WeftDom.$$null
+ ]
+ });
+}
+
+let make = SignInFields;
+
+export {
+ make,
+}
+/* Hooks-Weft Not a pure module */
diff --git a/examples/weft_shop/src/client/SignInForm.res b/examples/weft_shop/src/client/SignInForm.res
new file mode 100644
index 0000000..8c869aa
--- /dev/null
+++ b/examples/weft_shop/src/client/SignInForm.res
@@ -0,0 +1,14 @@
+/*** The sign-in page's form: once signed in, on to the page that asked for it (`next`). */
+
+@weft.component({kind: Entrypoint})
+let make = (~next: string) => {
+ let navigation = WeftServer.Navigation.useNavigation()
+
+ {
+ Store.toast(`Signed in as ${name}`)
+ navigation->WeftServer.Navigation.navigate(next)->ignore
+ }}
+ />
+
+}
diff --git a/examples/weft_shop/src/client/SignInForm.res.js b/examples/weft_shop/src/client/SignInForm.res.js
new file mode 100644
index 0000000..8a90a1a
--- /dev/null
+++ b/examples/weft_shop/src/client/SignInForm.res.js
@@ -0,0 +1,31 @@
+'use weft entrypoint'
+// Generated by ReScript, PLEASE EDIT WITH CARE
+
+import * as Jsx$WeftDom from "@e-cline/weft_dom/src/shared/Jsx.res.js";
+import * as Store$WeftShop from "./Store.res.js";
+import * as Entrypoint$Weft from "@e-cline/weft/src/shared/Entrypoint.res.js";
+import * as Navigation$WeftServer from "@e-cline/weft_server/src/shared/Navigation.res.js";
+import * as SignInFields$WeftShop from "./SignInFields.res.js";
+
+function SignInForm(props) {
+ let next = props.next;
+ let navigation = Navigation$WeftServer.useNavigation();
+ return Jsx$WeftDom.Elements.jsx("div", {
+ id: "signin-page",
+ children: Jsx$WeftDom.jsx(SignInFields$WeftShop.make, {
+ onSignedIn: name => {
+ Store$WeftShop.toast(`Signed in as ` + name);
+ Navigation$WeftServer.navigate(navigation, next);
+ }
+ })
+ });
+}
+
+Entrypoint$Weft.mark(SignInForm, "@e-cline/weft_shop_example/src/client/SignInForm.res#");
+
+let make = SignInForm;
+
+export {
+ make,
+}
+/* Not a pure module */
diff --git a/examples/weft_shop/src/client/Store.res b/examples/weft_shop/src/client/Store.res
new file mode 100644
index 0000000..91f5c01
--- /dev/null
+++ b/examples/weft_shop/src/client/Store.res
@@ -0,0 +1,53 @@
+/***
+The client's own state, in `signals` atoms, which entrypoints anywhere on the page read and write:
+the cart an RPC last answered with, toasts, and whether the sign-in dialog is open.
+
+These are module-level, so they outlive a page (in a browser, a navigation; in the tests, a page
+opened after another in the same document), and on the server they'd be shared by every request:
+nothing writes them there (only handlers and effects do, which don't run on the server), and the
+cart is tagged with the `ShopShell` that wrote it, so a shell only believes its own.
+*/
+
+open Types
+
+/** The cart, and which shell's it is. */
+let cart: Signals.Atom.t