From d10a172ca1b9e2644d172282594fbfc836f3aecd Mon Sep 17 00:00:00 2001 From: Ethan Cline <7142767+e-cline@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:02:28 -0400 Subject: [PATCH 1/3] Fix five Weft bugs found building a storefront example - weft: a pending Suspense boundary whose content arrived after hydration started, and suspended as it hydrated (an entrypoint's chunk still loading), rendered its content again after the server's, which stayed unhydrated. It now stays dehydrated until what it waits on settles. - weft_signals: the hooks gave useSyncExternalStore no server snapshot, so an entrypoint reading a signal failed to render on the server (error 15) and the page fell back to client rendering. - weft_dom: hydration compared style values as written, while browsers read them back normalised (hsl as rgb, -0% as 0%), warning about styles that were the same. autoFocus now focuses an element rendered after the page loaded. New aria and form props. - weft_server: Navigation.navigate, load and refresh resolved when their transition started, not once the new page was on screen as documented. Bootstrap.start takes a ~signal that stops the page. - weft_vite_plugin: the harness keeps cookies as a browser does, and opening a page closes the one before (its root unmounted), so a page left open by one test can't act in the next. Co-Authored-By: Claude Opus 5.5 --- .../vite-plugin-weft-testing-cookies.md | 8 +++ .changeset/weft-core-dehydrated-retry.md | 8 +++ .changeset/weft-dom-hydration-and-props.md | 11 +++ .changeset/weft-server-navigation-commit.md | 9 +++ .changeset/weft-signals-server.md | 7 ++ packages/weft/src/shared/Reconciler.res | 10 +++ packages/weft/src/shared/Reconciler.res.js | 40 +++++++++-- packages/weft/tests/Hydration_test.res | 37 ++++++++++ packages/weft/tests/Hydration_test.res.js | 32 +++++++++ packages/weft_dom/README.md | 3 + packages/weft_dom/src/client/DomHost.res | 31 ++++++-- packages/weft_dom/src/client/DomHost.res.js | 24 ++++++- packages/weft_dom/src/shared/DomProps.res | 15 ++++ packages/weft_dom/src/shared/DomProps.res.js | 6 ++ packages/weft_dom/tests/Client_test.res | 41 ++++++++++- packages/weft_dom/tests/Client_test.res.js | 61 +++++++++++++++- packages/weft_dom/tests/Html_test.res | 5 +- packages/weft_dom/tests/Html_test.res.js | 9 ++- packages/weft_dom/tests/Hydrate_test.res | 24 +++++++ packages/weft_dom/tests/Hydrate_test.res.js | 28 ++++++++ packages/weft_server/README.md | 6 +- packages/weft_server/src/client/Bootstrap.res | 17 ++++- .../weft_server/src/client/Bootstrap.res.js | 21 ++++-- .../weft_server/src/client/Bootstrap.resi | 5 ++ .../weft_server/src/shared/Navigation.res | 47 +++++++++++- .../weft_server/src/shared/Navigation.res.js | 48 ++++++++++--- .../weft_server/src/shared/Navigation.resi | 14 ++-- .../weft_server/tests/HydratedPage_test.res | 39 ++++++++++ .../tests/HydratedPage_test.res.js | 28 +++++++- .../weft_server/tests/Navigation_test.res | 54 ++++++++++---- .../weft_server/tests/Navigation_test.res.js | 56 +++++++++++---- packages/weft_signals/src/SignalHooks.res | 3 +- packages/weft_signals/src/SignalHooks.res.js | 2 +- packages/weft_signals/src/SignalHooks.resi | 5 ++ .../weft_signals/tests/SignalHooks_test.res | 31 ++++++++ .../tests/SignalHooks_test.res.js | 33 +++++++++ tools/weft_vite_plugin/README.md | 9 ++- tools/weft_vite_plugin/src/WeftTesting.res | 7 +- tools/weft_vite_plugin/src/WeftTesting.res.js | 17 ++++- tools/weft_vite_plugin/src/WeftTesting.resi | 16 +++++ tools/weft_vite_plugin/src/testing.ts | 72 ++++++++++++++++++- .../tests/fixtures/basic/src/Server.res | 7 +- .../tests/fixtures/basic/src/Server.res.js | 16 +++++ tools/weft_vite_plugin/tests/harness.test.ts | 22 ++++++ 44 files changed, 912 insertions(+), 72 deletions(-) create mode 100644 .changeset/vite-plugin-weft-testing-cookies.md create mode 100644 .changeset/weft-core-dehydrated-retry.md create mode 100644 .changeset/weft-dom-hydration-and-props.md create mode 100644 .changeset/weft-server-navigation-commit.md create mode 100644 .changeset/weft-signals-server.md 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/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-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/packages/weft/src/shared/Reconciler.res b/packages/weft/src/shared/Reconciler.res index 422df91..ae9fc10 100644 --- a/packages/weft/src/shared/Reconciler.res +++ b/packages/weft/src/shared/Reconciler.res @@ -323,6 +323,16 @@ module MakeHydratableWithScheduler = (Host: HostConfig.Hydratable, Sched: Schedu | (Some(_), Some(instance), _) => renderBoundaryOnClient(unit, instance) Begin + // A dehydrated boundary whose content suspended as its retry hydrated it (an entrypoint's + // chunk still loading, say): dehydrated again, keeping the server's content, until what it + // waits on settles. (Its retry began, so it no longer looks dehydrated: rendering it now would + // render its content on the client, beside the server's.) + | (Some(_), None, None) if leftDehydrated->Map.has(unit) => + unit.memoizedState = dehydratedToUnknown({ + dehydrated: leftDehydrated->Map.get(unit)->Option.getUnsafe, + }) + unit.child = None + Skip // A dehydrated boundary rendering again: its content has arrived, it failed, or it got // new props first. | (Some(current), None, Some(instance)) => diff --git a/packages/weft/src/shared/Reconciler.res.js b/packages/weft/src/shared/Reconciler.res.js index 3fe80ae..2200305 100644 --- a/packages/weft/src/shared/Reconciler.res.js +++ b/packages/weft/src/shared/Reconciler.res.js @@ -375,7 +375,15 @@ function MakeHydratableWithScheduler(Host) { return "Begin"; } if (match$7 === undefined) { - return "Begin"; + if (leftDehydrated.has(unit)) { + unit.memoizedState = { + dehydrated: leftDehydrated.get(unit) + }; + unit.child = undefined; + return "Skip"; + } else { + return "Begin"; + } } let instance$5 = Primitive_option.valFromOption(match$7); let retrying = Lanes$Weft.intersects(unit.lanes, workInProgressLanes.contents); @@ -1715,7 +1723,15 @@ function MakeWithScheduler(Host) { return "Begin"; } if (match$7 === undefined) { - return "Begin"; + if (leftDehydrated.has(unit)) { + unit.memoizedState = { + dehydrated: leftDehydrated.get(unit) + }; + unit.child = undefined; + return "Skip"; + } else { + return "Begin"; + } } let instance$5 = Primitive_option.valFromOption(match$7); let retrying = Lanes$Weft.intersects(unit.lanes, workInProgressLanes.contents); @@ -3056,7 +3072,15 @@ function Make(Host) { return "Begin"; } if (match$7 === undefined) { - return "Begin"; + if (leftDehydrated.has(unit)) { + unit.memoizedState = { + dehydrated: leftDehydrated.get(unit) + }; + unit.child = undefined; + return "Skip"; + } else { + return "Begin"; + } } let instance$5 = Primitive_option.valFromOption(match$7); let retrying = Lanes$Weft.intersects(unit.lanes, workInProgressLanes.contents); @@ -4356,7 +4380,15 @@ function MakeHydratable(Host) { return "Begin"; } if (match$7 === undefined) { - return "Begin"; + if (leftDehydrated.has(unit)) { + unit.memoizedState = { + dehydrated: leftDehydrated.get(unit) + }; + unit.child = undefined; + return "Skip"; + } else { + return "Begin"; + } } let instance$5 = Primitive_option.valFromOption(match$7); let retrying = Lanes$Weft.intersects(unit.lanes, workInProgressLanes.contents); diff --git a/packages/weft/tests/Hydration_test.res b/packages/weft/tests/Hydration_test.res index 7efeb3d..3a4a7ef 100644 --- a/packages/weft/tests/Hydration_test.res +++ b/packages/weft/tests/Hydration_test.res @@ -466,6 +466,43 @@ describe("hydrating boundaries", () => { }, ) + testAsync( + "keeps a pending boundary's content when hydrating it suspends once it arrives, and hydrates it later", + async t => { + // An entrypoint's chunk still loading when the boundary's content streams in, say. + let data = deferred() + let element = +
+ {text("loading")} }> + + +

{text("after")}

+
+ let (renderer, warnings) = logging( + () => { + let renderer = TestRenderer.hydrate( + `
loading

after

`, + element, + ) + TestRenderer.resolveBoundary(renderer, `ready`) + renderer + }, + ) + // The server's content, as it arrived: not the client's fallback, nor a copy after it. + t + ->expect(TestRenderer.toMarkup(renderer)) + ->Expect.toBe(`
ready

after

`) + data.resolve("ready") + await settle() + t + ->expect(TestRenderer.toMarkup(renderer)) + ->Expect.toBe(`
ready

after

`) + t->expect(created(renderer))->Expect.toEqual([]) + t->expect(TestRenderer.ops(renderer)->Array.includes("hydrate b#r"))->Expect.toBe(true) + t->expect(warnings)->Expect.toEqual([]) + }, + ) + testAsync( "keeps a boundary waiting to hydrate as the server rendered it while something beside it updates", async t => { diff --git a/packages/weft/tests/Hydration_test.res.js b/packages/weft/tests/Hydration_test.res.js index 29f7d49..7c2cb5c 100644 --- a/packages/weft/tests/Hydration_test.res.js +++ b/packages/weft/tests/Hydration_test.res.js @@ -442,6 +442,38 @@ Vitest.describe("hydrating boundaries", undefined, undefined, undefined, undefin t.expect(created(renderer)).toEqual([]); return t.expect(TestRenderer$Weft.ops(renderer).includes("hydrate b#r")).toBe(true); }); + Vitest.testAsync("keeps a pending boundary's content when hydrating it suspends once it arrives, and hydrates it later", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, async t => { + let data = deferred(); + let element = TestJsx$Weft.Elements.jsxs("div", { + children: [ + TestJsx$Weft.jsx(Suspense$Weft.make, { + fallback: TestJsx$Weft.Elements.jsx("i", { + children: "loading" + }), + children: TestJsx$Weft.jsx(Hydration_test$Reads, { + promise: data.promise, + id: "r" + }) + }), + TestJsx$Weft.Elements.jsx("p", { + children: "after" + }) + ] + }); + let match = logging(() => { + let renderer = TestRenderer$Weft.hydrate(`
loading

after

`, element); + TestRenderer$Weft.resolveBoundary(renderer, `ready`); + return renderer; + }); + let renderer = match[0]; + t.expect(TestRenderer$Weft.toMarkup(renderer)).toBe(`
ready

after

`); + data.resolve("ready"); + await settle(); + t.expect(TestRenderer$Weft.toMarkup(renderer)).toBe(`
ready

after

`); + t.expect(created(renderer)).toEqual([]); + t.expect(TestRenderer$Weft.ops(renderer).includes("hydrate b#r")).toBe(true); + return t.expect(match[1]).toEqual([]); + }); Vitest.testAsync("keeps a boundary waiting to hydrate as the server rendered it while something beside it updates", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, async t => { let data = deferred(); let element = TestJsx$Weft.Elements.jsxs("div", { diff --git a/packages/weft_dom/README.md b/packages/weft_dom/README.md index 893221b..8279b81 100644 --- a/packages/weft_dom/README.md +++ b/packages/weft_dom/README.md @@ -58,6 +58,9 @@ switch WeftDom.Dom.document->WeftDom.Dom.body->WeftDom.Dom.querySelector("#app") keystroke. - Everything else is an attribute: `className` is `class`, `htmlFor` is `for`, `type_` is `type`, `ariaLabel` is `aria-label`, and `true`/`false` add or remove a boolean attribute. +- `autoFocus` is the attribute too, which the browser only acts on for elements there as the page + loads (the server's HTML): an element rendered later with `autoFocus` (a dialog's field) is + focused once the commit has put it in the page. - `ref` receives the element. ## Behaviour diff --git a/packages/weft_dom/src/client/DomHost.res b/packages/weft_dom/src/client/DomHost.res index bc846b8..56723a9 100644 --- a/packages/weft_dom/src/client/DomHost.res +++ b/packages/weft_dom/src/client/DomHost.res @@ -162,11 +162,21 @@ let createTextInstance = (text, ~container) => let appendInitialChild = Dom.appendChild -let finalizeInitialChildren = (element, ~tag as _, ~props) => { +/** +A new element with `autoFocus`, to focus once the commit has put it in the page: the attribute alone +only focuses an element that's there as the page loads, as the browser parses it (the server's +HTML), not one rendered later. +*/ +let autoFocused: ref> = ref(Nullable.null) + +let finalizeInitialChildren = (element, ~tag as _, ~props: props) => { propsToDict(props)->Dict.forEachWithKey((next, name) => setProp(element, name, ~previous=nothing, ~next) ) storeProps(element, props) + if props.autoFocus == Some(true) { + autoFocused := Nullable.make(element) + } } let prepareUpdate = (_: Dom.element, ~tag as _, ~oldProps, ~newProps) => { @@ -333,6 +343,12 @@ let resetAfterCommit = container => { | _ => () } focused := Nullable.null + // A render thrown away before committing leaves one that's never attached: it isn't focused. + switch Nullable.toOption(autoFocused.contents) { + | Some(element) if Dom.isConnected(element) => Dom.focus(element) + | _ => () + } + autoFocused := Nullable.null scheduleReplay() } @@ -425,10 +441,17 @@ let attributeDifferences = (element: Dom.element, props: props) => { | name if isEventProp(name) => () | "style" => let style = unknownToStyle(value)->Nullable.toOption->Option.getOr(Dict.make()) - style->Dict.forEachWithKey((expected, key) => { - let actual = Dom.style(element)->getPropertyValue(DomProps.cssName(key)) + // The browser reads a style back in its own form (`hsl(...)` as `rgb(...)`, `-0%` as `0%`): + // the client's value is set on an element of its own and read back the same way, so the same + // style written another way isn't a difference. + let scratch = Dom.style(element->Dom.ownerDocument->Dom.createElement("div")) + style->Dict.forEachWithKey((value, key) => { + let name = DomProps.cssName(key) + Dom.setStyleProperty(scratch, name, value) + let expected = scratch->getPropertyValue(name) + let actual = Dom.style(element)->getPropertyValue(name) if actual != expected { - differences->Array.push(`style ${DomProps.cssName(key)}: "${actual}", not "${expected}"`) + differences->Array.push(`style ${name}: "${actual}", not "${expected}"`) } }) | name => diff --git a/packages/weft_dom/src/client/DomHost.res.js b/packages/weft_dom/src/client/DomHost.res.js index 8d9e2c1..efe0c1f 100644 --- a/packages/weft_dom/src/client/DomHost.res.js +++ b/packages/weft_dom/src/client/DomHost.res.js @@ -164,9 +164,17 @@ function appendInitialChild(prim0, prim1) { prim0.appendChild(prim1); } +let autoFocused = { + contents: null +}; + function finalizeInitialChildren(element, param, props) { Stdlib_Dict.forEachWithKey(props, (next, name) => setProp(element, name, undefined, next)); element.$$resFiberProps = props; + if (Primitive_object.equal(props.autoFocus, true)) { + autoFocused.contents = element; + return; + } } function prepareUpdate(param, param$1, oldProps, newProps) { @@ -323,6 +331,11 @@ function resetAfterCommit(container) { element.focus(); } focused.contents = null; + let element$1 = autoFocused.contents; + if (!(element$1 == null) && element$1.isConnected) { + element$1.focus(); + } + autoFocused.contents = null; scheduleReplay(); } @@ -444,10 +457,14 @@ function attributeDifferences(element, props) { } if (name === "style") { let style = Stdlib_Option.getOr((value == null) ? undefined : Primitive_option.some(value), {}); - return Stdlib_Dict.forEachWithKey(style, (expected, key) => { - let actual = element.style.getPropertyValue(DomProps$WeftDom.cssName(key)); + let scratch = element.ownerDocument.createElement("div").style; + return Stdlib_Dict.forEachWithKey(style, (value, key) => { + let name = DomProps$WeftDom.cssName(key); + scratch.setProperty(name, value); + let expected = scratch.getPropertyValue(name); + let actual = element.style.getPropertyValue(name); if (actual !== expected) { - differences.push(`style ` + DomProps$WeftDom.cssName(key) + `: "` + actual + `", not "` + expected + `"`); + differences.push(`style ` + name + `: "` + actual + `", not "` + expected + `"`); return; } }); @@ -600,6 +617,7 @@ export { createInstance, createTextInstance, appendInitialChild, + autoFocused, finalizeInitialChildren, prepareUpdate, commitUpdate, diff --git a/packages/weft_dom/src/shared/DomProps.res b/packages/weft_dom/src/shared/DomProps.res index 8bf66cb..e1984d1 100644 --- a/packages/weft_dom/src/shared/DomProps.res +++ b/packages/weft_dom/src/shared/DomProps.res @@ -30,6 +30,14 @@ type props = { @as("aria-checked") ariaChecked?: string, @as("aria-describedby") ariaDescribedby?: string, @as("aria-labelledby") ariaLabelledby?: string, + @as("aria-expanded") ariaExpanded?: string, + @as("aria-selected") ariaSelected?: string, + @as("aria-controls") ariaControls?: string, + @as("aria-current") ariaCurrent?: string, + @as("aria-hidden") ariaHidden?: string, + @as("aria-live") ariaLive?: string, + @as("aria-modal") ariaModal?: string, + @as("aria-busy") ariaBusy?: string, @as("data-testid") dataTestId?: string, // Links and media href?: string, @@ -48,6 +56,10 @@ type props = { placeholder?: string, readOnly?: bool, autoFocus?: bool, + required?: bool, + autoComplete?: string, + inputMode?: string, + maxLength?: int, htmlFor?: string, min?: string, max?: string, @@ -106,6 +118,9 @@ let attributeName = (prop: string) => | "readOnly" => "readonly" | "autoFocus" => "autofocus" | "colSpan" => "colspan" + | "autoComplete" => "autocomplete" + | "inputMode" => "inputmode" + | "maxLength" => "maxlength" | name => name } diff --git a/packages/weft_dom/src/shared/DomProps.res.js b/packages/weft_dom/src/shared/DomProps.res.js index 1ee3d48..73fbf20 100644 --- a/packages/weft_dom/src/shared/DomProps.res.js +++ b/packages/weft_dom/src/shared/DomProps.res.js @@ -19,6 +19,8 @@ function eventName(prop) { function attributeName(prop) { switch (prop) { + case "autoComplete" : + return "autocomplete"; case "autoFocus" : return "autofocus"; case "className" : @@ -27,6 +29,10 @@ function attributeName(prop) { return "colspan"; case "htmlFor" : return "for"; + case "inputMode" : + return "inputmode"; + case "maxLength" : + return "maxlength"; case "readOnly" : return "readonly"; case "tabIndex" : diff --git a/packages/weft_dom/tests/Client_test.res b/packages/weft_dom/tests/Client_test.res index b3c4415..36a6579 100644 --- a/packages/weft_dom/tests/Client_test.res +++ b/packages/weft_dom/tests/Client_test.res @@ -375,10 +375,11 @@ describe("More DOM cases", () => { t->expect(clicks.contents)->Expect.toBe(1) }) - test("renamed attributes: autoFocus, readOnly, tabIndex, colSpan", t => { + test("renamed attributes: autoFocus, readOnly, tabIndex, colSpan, and the form hints", t => { let (container, _) = mount(
+ @@ -399,6 +400,44 @@ describe("More DOM cases", () => { t ->expect(Dom.getAttribute(find(container, "#cell"), "colspan")) ->Expect.toEqual(Nullable.make("2")) + let hints = find(container, "#hints") + t + ->expect( + ["autocomplete", "inputmode", "maxlength", "required"]->Array.map( + name => Dom.getAttribute(hints, name)->Nullable.toOption, + ), + ) + ->Expect.toEqual([Some("email"), Some("email"), Some("40"), Some("")]) + }) + + test("autoFocus focuses an element rendered after the page loaded, once it's in the page", t => { + let (container, root) = mount( +
+
, + ) + let other = find(container, "#other") + Dom.focus(other) + rerender( + root, +
+
, + ) + t + ->expect(Dom.activeElement(Dom.document) == Nullable.make(find(container, "#dialog-input"))) + ->Expect.toBe(true) + // Only when it's new: rendering it again leaves the focus where it is. + Dom.focus(other) + rerender( + root, +
+
, + ) + t->expect(Dom.activeElement(Dom.document) == Nullable.make(other))->Expect.toBe(true) }) test("removing the value prop empties the input", t => { diff --git a/packages/weft_dom/tests/Client_test.res.js b/packages/weft_dom/tests/Client_test.res.js index 801d64a..9fbdc2f 100644 --- a/packages/weft_dom/tests/Client_test.res.js +++ b/packages/weft_dom/tests/Client_test.res.js @@ -11,6 +11,7 @@ import * as Client$WeftDom from "../src/client/Client.res.js"; import * as Portal$WeftDom from "../src/shared/Portal.res.js"; import * as Stdlib_Nullable from "@rescript/runtime/lib/es6/Stdlib_Nullable.js"; import * as Transition$Weft from "@e-cline/weft/src/shared/Transition.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"; function makeContainer() { @@ -514,7 +515,7 @@ Vitest.describe("More DOM cases", undefined, undefined, undefined, undefined, un })); t.expect(clicks.contents).toBe(1); }); - Vitest.test("renamed attributes: autoFocus, readOnly, tabIndex, colSpan", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, t => { + Vitest.test("renamed attributes: autoFocus, readOnly, tabIndex, colSpan, and the form hints", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, t => { let match = mount(Jsx$WeftDom.Elements.jsxs("div", { children: [ Jsx$WeftDom.Elements.jsx("input", { @@ -523,6 +524,13 @@ Vitest.describe("More DOM cases", undefined, undefined, undefined, undefined, un readOnly: true, autoFocus: true }), + Jsx$WeftDom.Elements.jsx("input", { + id: "hints", + required: true, + autoComplete: "email", + inputMode: "email", + maxLength: 40 + }), Jsx$WeftDom.Elements.jsx("table", { children: Jsx$WeftDom.Elements.jsx("tbody", { children: Jsx$WeftDom.Elements.jsx("tr", { @@ -547,6 +555,57 @@ Vitest.describe("More DOM cases", undefined, undefined, undefined, undefined, un "3" ]); t.expect(find(container, "#cell").getAttribute("colspan")).toEqual("2"); + let hints = find(container, "#hints"); + t.expect([ + "autocomplete", + "inputmode", + "maxlength", + "required" + ].map(name => Primitive_option.fromNullable(hints.getAttribute(name)))).toEqual([ + "email", + "email", + "40", + "" + ]); + }); + Vitest.test("autoFocus focuses an element rendered after the page loaded, once it's in the page", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, t => { + let match = mount(Jsx$WeftDom.Elements.jsx("div", { + children: Jsx$WeftDom.Elements.jsx("button", { + id: "other" + }) + })); + let root = match[1]; + let container = match[0]; + let other = find(container, "#other"); + other.focus(); + let element = Jsx$WeftDom.Elements.jsxs("div", { + children: [ + Jsx$WeftDom.Elements.jsx("button", { + id: "other" + }), + Jsx$WeftDom.Elements.jsx("input", { + id: "dialog-input", + autoFocus: true + }) + ] + }); + Client$WeftDom.act(() => Client$WeftDom.render(root, element)); + t.expect(Primitive_object.equal(document.activeElement, find(container, "#dialog-input"))).toBe(true); + other.focus(); + let element$1 = Jsx$WeftDom.Elements.jsxs("div", { + children: [ + Jsx$WeftDom.Elements.jsx("button", { + id: "other" + }), + Jsx$WeftDom.Elements.jsx("input", { + id: "dialog-input", + placeholder: "again", + autoFocus: true + }) + ] + }); + Client$WeftDom.act(() => Client$WeftDom.render(root, element$1)); + t.expect(Primitive_object.equal(document.activeElement, other)).toBe(true); }); Vitest.test("removing the value prop empties the input", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, t => { let match = mount(Jsx$WeftDom.Elements.jsx("input", { diff --git a/packages/weft_dom/tests/Html_test.res b/packages/weft_dom/tests/Html_test.res index 4625a3e..9ae0b95 100644 --- a/packages/weft_dom/tests/Html_test.res +++ b/packages/weft_dom/tests/Html_test.res @@ -78,11 +78,12 @@ describe("host elements and props", () => { await html( , ), ) - ->Expect.toBe(``) + ->Expect.toBe(``) }) testAsync("leaves out event handlers and refs", async t => { diff --git a/packages/weft_dom/tests/Html_test.res.js b/packages/weft_dom/tests/Html_test.res.js index 39ac6a5..871b75b 100644 --- a/packages/weft_dom/tests/Html_test.res.js +++ b/packages/weft_dom/tests/Html_test.res.js @@ -129,11 +129,18 @@ Vitest.describe("host elements and props", undefined, undefined, undefined, unde children: "L" }), Jsx$WeftDom.Elements.jsx("button", { + "aria-expanded": "false", disabled: false, children: "B" + }), + Jsx$WeftDom.Elements.jsx("input", { + required: true, + autoComplete: "email", + inputMode: "email", + maxLength: 40 }) ] - }), undefined, undefined)).toBe(``)); + }), undefined, undefined)).toBe(``)); Vitest.testAsync("leaves out event handlers and refs", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, async t => { let ref = { current: null diff --git a/packages/weft_dom/tests/Hydrate_test.res b/packages/weft_dom/tests/Hydrate_test.res index 3fdd74a..d131c9d 100644 --- a/packages/weft_dom/tests/Hydrate_test.res +++ b/packages/weft_dom/tests/Hydrate_test.res @@ -345,6 +345,30 @@ describe("hydrating the server's HTML", () => { ]) }) + testAsync( + "compares styles as the browser reads them, so the same style written another way isn't a difference", + async t => { + let style = Dict.fromArray([("color", "#ff0000"), ("margin-top", "0px")]) + // What the server wrote for the same props, which the browser keeps in its own form. + let html = await Html.render(

{string("x")}

)->Html.shell + let container = page(html) + let (_, warnings) = await logging( + async () => hydrate(container,

{string("x")}

), + ) + t->expect(warnings)->Expect.toEqual([]) + // A style that does differ still warns, in the browser's form. + let container = page(`

x

`) + let (_, warnings) = await logging( + async () => hydrate(container,

{string("x")}

), + ) + t + ->expect(warnings) + ->Expect.toEqual([ + `WeftDom: the server's

has other attributes than the client's (style color: "blue", not "rgb(255, 0, 0)"; style margin-top: "", not "0px"); they're left as the server wrote them`, + ]) + }, + ) + testAsync( "renders the boundary that doesn't match on the client, and keeps the rest", async t => { diff --git a/packages/weft_dom/tests/Hydrate_test.res.js b/packages/weft_dom/tests/Hydrate_test.res.js index a141407..742998a 100644 --- a/packages/weft_dom/tests/Hydrate_test.res.js +++ b/packages/weft_dom/tests/Hydrate_test.res.js @@ -420,6 +420,34 @@ Vitest.describe("hydrating the server's HTML", undefined, undefined, undefined, t.expect(find(container, "p").getAttribute("title")).toEqual("server"); return t.expect(match[1]).toEqual([`WeftDom: the server's

has other attributes than the client's (title: "server", not "client"); they're left as the server wrote them`]); }); + Vitest.testAsync("compares styles as the browser reads them, so the same style written another way isn't a difference", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, async t => { + let style = Object.fromEntries([ + [ + "color", + "#ff0000" + ], + [ + "margin-top", + "0px" + ] + ]); + let html = await Html$WeftDom.shell(Html$WeftDom.render(Jsx$WeftDom.Elements.jsx("p", { + style: style, + children: "x" + }), undefined, undefined, undefined)); + let container = DomSupport$WeftDom.page(html); + let match = await logging(async () => hydrate(container, Jsx$WeftDom.Elements.jsx("p", { + style: style, + children: "x" + }))); + t.expect(match[1]).toEqual([]); + let container$1 = DomSupport$WeftDom.page(`

x

`); + let match$1 = await logging(async () => hydrate(container$1, Jsx$WeftDom.Elements.jsx("p", { + style: style, + children: "x" + }))); + return t.expect(match$1[1]).toEqual([`WeftDom: the server's

has other attributes than the client's (style color: "blue", not "rgb(255, 0, 0)"; style margin-top: "", not "0px"); they're left as the server wrote them`]); + }); Vitest.testAsync("renders the boundary that doesn't match on the client, and keeps the rest", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, async t => { let element = Jsx$WeftDom.Elements.jsxs("div", { children: [ diff --git a/packages/weft_server/README.md b/packages/weft_server/README.md index 27dcad8..28bc5fa 100644 --- a/packages/weft_server/README.md +++ b/packages/weft_server/README.md @@ -7,7 +7,8 @@ rendered as HTML on the server, then hydrated. It's phases 3, 5 and 6 of [the server components plan](../../docs/weft_server_components_plan.md), and the pages' HTML is [its own plan](../../docs/weft_ssr_hydration_plan.md). [The Vite plugin](../../tools/weft_vite_plugin) bundles an app using it, and -[`examples/weft_notes`](../../examples/weft_notes) is one. +[`examples/weft_notes`](../../examples/weft_notes) and +[`examples/weft_shop`](../../examples/weft_shop) are two. ## Installing @@ -59,6 +60,9 @@ navigation->WeftServer.Navigation.navigate("/notes/2") // fetch the payload, ren navigation->WeftServer.Navigation.refresh // fetch this page's again ``` +Both resolve once the new page is on screen (its transition has committed), so what awaits them +(clearing a pending state, say) runs when the old page has gone. + A link's `onClick` should use `followLink`, which navigates in place only for a plain click on a link to this origin, and leaves the rest to the browser (one opening a new tab or window, a `download`, a `target`, a link to a part of the page): diff --git a/packages/weft_server/src/client/Bootstrap.res b/packages/weft_server/src/client/Bootstrap.res index b066021..4666aee 100644 --- a/packages/weft_server/src/client/Bootstrap.res +++ b/packages/weft_server/src/client/Bootstrap.res @@ -41,13 +41,14 @@ external pushState: (Nullable.t, string, string) => unit = "pushState" @val @scope("location") external assign: string => unit = "assign" @val @scope("location") external replace: string => unit = "replace" @val external addEventListener: (string, unit => unit) => unit = "addEventListener" +@val external removeEventListener: (string, unit => unit) => unit = "removeEventListener" @val external document: WeftDom.Dom.document = "document" @send external getElementById: (WeftDom.Dom.document, string) => Nullable.t = "getElementById" @send external getAttribute: (WeftDom.Dom.element, string) => Nullable.t = "getAttribute" -let start = async (~loadModule, ~fetch=?, ~container=?, ~url=?, ~onRefresh=?, ()) => { +let start = async (~loadModule, ~fetch=?, ~container=?, ~url=?, ~onRefresh=?, ~signal=?, ()) => { let client = Decoder.make(~loadModule, ~fetch?) let container = switch container { | Some(container) => container @@ -69,14 +70,24 @@ let start = async (~loadModule, ~fetch=?, ~container=?, ~url=?, ~onRefresh=?, () // loaded the ordinary way, for the server's own answer. ~onFailed=(url, ~push) => push ? assign(url) : replace(url), ) - addEventListener("popstate", () => navigation->Navigation.load(pathname ++ search)->ignore) + let onPopState = () => navigation->Navigation.load(pathname ++ search)->ignore + addEventListener("popstate", onPopState) + // Stopping the page: nothing of it runs any more (its effects cleaned up, its handlers gone). + signal->Option.forEach(signal => signal->Streams.Signal.onAbort(() => { + removeEventListener("popstate", onPopState) + root.contents->Option.forEach(WeftDom.Client.unmount) + root := None + }, {once: true})) onRefresh->Option.forEach(onRefresh => onRefresh(() => navigation->Navigation.refresh->ignore)) if !hydrating { // At once, as before: the container is emptied while the payload is still coming. root := Some(WeftDom.Client.createRoot(container)) } let element = await client->Decoder.fromStream(rows(self)) - if hydrating { + let stopped = signal->Option.mapOr(false, Streams.Signal.aborted) + if stopped { + () + } else if hydrating { // The same tree the server's HTML was rendered from: the page's context around the payload. root := Some(WeftDom.Client.hydrateRoot(container, Navigation.wrap(navigation, element))) } else { diff --git a/packages/weft_server/src/client/Bootstrap.res.js b/packages/weft_server/src/client/Bootstrap.res.js index 4bd20be..abeb87a 100644 --- a/packages/weft_server/src/client/Bootstrap.res.js +++ b/packages/weft_server/src/client/Bootstrap.res.js @@ -39,7 +39,7 @@ function rows(target) { }); } -async function start(loadModule, fetch, container, url, onRefresh, param) { +async function start(loadModule, fetch, container, url, onRefresh, signal, param) { let client = Decoder$WeftServer.make(loadModule, fetch); let container$1; if (container !== undefined) { @@ -61,8 +61,18 @@ async function start(loadModule, fetch, container, url, onRefresh, param) { location.replace(url); } }); - addEventListener("popstate", () => { + let onPopState = () => { Navigation$WeftServer.load(navigation, location.pathname + location.search); + }; + addEventListener("popstate", onPopState); + Stdlib_Option.forEach(signal, signal => { + signal.addEventListener("abort", () => { + removeEventListener("popstate", onPopState); + Stdlib_Option.forEach(root.contents, Client$WeftDom.unmount); + root.contents = undefined; + }, { + once: true + }); }); Stdlib_Option.forEach(onRefresh, onRefresh => onRefresh(() => { Navigation$WeftServer.refresh(navigation); @@ -71,7 +81,10 @@ async function start(loadModule, fetch, container, url, onRefresh, param) { root.contents = Primitive_option.some(Client$WeftDom.createRoot(container$1)); } let element = await Decoder$WeftServer.fromStream(client, rows(self), undefined); - if (hydrating) { + let stopped = Stdlib_Option.mapOr(signal, false, prim => prim.aborted); + if (stopped) { + + } else if (hydrating) { root.contents = Primitive_option.some(Client$WeftDom.hydrateRoot(undefined, container$1, Navigation$WeftServer.wrap(navigation, element))); } else { Navigation$WeftServer.show(navigation, element); @@ -85,7 +98,7 @@ function main(loadModule, onRefresh) { return; } let begin = container => { - start(loadModule, undefined, Primitive_option.some(container), undefined, onRefresh, undefined); + start(loadModule, undefined, Primitive_option.some(container), undefined, onRefresh, undefined, undefined); }; let container = Primitive_option.fromNullable(document.getElementById("root")); if (container !== undefined) { diff --git a/packages/weft_server/src/client/Bootstrap.resi b/packages/weft_server/src/client/Bootstrap.resi index 2421bf3..6749f9d 100644 --- a/packages/weft_server/src/client/Bootstrap.resi +++ b/packages/weft_server/src/client/Bootstrap.resi @@ -18,6 +18,10 @@ Starts the page: decodes the rows on `self` into `container` (by default `#root` server's HTML there if the page has it (`data-weft-html` on the container), as the client `loadModule` and `fetch` (by default `Decoder.webFetch`) make. `url` is the page's (by default the location's path and query). Resolves with the page, once its first payload has rendered. + +Aborting `signal` stops the page: its root is unmounted (effects cleaned up, intervals and +subscriptions with them) and it no longer follows the history. A browser has no need of it (leaving +the page stops it); a test that opens one page after another in the same document does. */ let start: ( ~loadModule: Decoder.loadModule, @@ -25,6 +29,7 @@ let start: ( ~container: WeftDom.Dom.element=?, ~url: string=?, ~onRefresh: (unit => unit) => unit=?, + ~signal: Streams.Signal.t=?, unit, ) => promise diff --git a/packages/weft_server/src/shared/Navigation.res b/packages/weft_server/src/shared/Navigation.res index 685b199..465c0b0 100644 --- a/packages/weft_server/src/shared/Navigation.res +++ b/packages/weft_server/src/shared/Navigation.res @@ -10,6 +10,11 @@ type t = { latest: Decoder.Latest.t, /** The server's page, which an HTML render reads: it can't fetch anything. */ onServer: bool, + /** + What resolves the promise of the `navigate`, `load` or `refresh` whose page is rendering: when it + commits, or when a newer one replaces it. + */ + mutable rendering: option unit>, } let context: Weft.Context.t> = Weft.Context.make(None) @@ -23,6 +28,7 @@ let make = (client, ~render, ~url, ~onNavigate=_ => (), ~onFailed=?) => { onFailed, latest: Decoder.Latest.make(), onServer: false, + rendering: None, } let makeForServer = (client, ~url) => { @@ -33,6 +39,7 @@ let makeForServer = (client, ~url) => { onFailed: None, latest: Decoder.Latest.make(), onServer: true, + rendering: None, } let checkOnClient = navigation => @@ -45,19 +52,55 @@ let checkOnClient = navigation => ) } -let wrap = (navigation, element) => +type rootProps = { + navigation: t, + element: Element.t, + /** Called once this page has committed. */ + onCommit: option unit>, +} + +/** The page's root: its context, and what tells a `navigate` its page is on screen. */ +let root = ({navigation, element, onCommit}: rootProps) => { + Weft.Hooks.useLayoutEffect1(() => { + onCommit->Option.forEach(onCommit => onCommit()) + None + }, [onCommit]) Weft.JsxCore.jsx(provider, {value: Some(navigation), children: element}) +} + +let wrapWith = (navigation, element, ~onCommit) => + Weft.JsxCore.jsx(root, {navigation, element, onCommit}) + +let wrap = (navigation, element) => wrapWith(navigation, element, ~onCommit=None) let show = (navigation, element) => navigation.render(wrap(navigation, element)) +/** Renders `element` in a transition; resolves once it has committed, or a newer page replaced it. */ +let showInTransition = (navigation, element) => + Promise.make((resolve, _) => { + // The page rendering before this one won't commit now: its caller is told it was replaced. + navigation.rendering->Option.forEach(replaced => replaced()) + let rec onCommit = () => { + if navigation.rendering === Some(onCommit) { + navigation.rendering = None + } + resolve() + } + navigation.rendering = Some(onCommit) + Weft.Transition.start(() => + navigation.render(wrapWith(navigation, element, ~onCommit=Some(onCommit))) + ) + }) + let fetchAndShowOnClient = async (navigation, url, ~push, ~canFallBack) => switch await navigation.latest->Decoder.Latest.fetchElement(navigation.client, url) { | Some(element) => navigation.url = url - Weft.Transition.start(() => show(navigation, element)) + let shown = showInTransition(navigation, element) if push { navigation.onNavigate(url) } + await shown | None => () | exception error => switch navigation.onFailed { diff --git a/packages/weft_server/src/shared/Navigation.res.js b/packages/weft_server/src/shared/Navigation.res.js index c321863..93e2e47 100644 --- a/packages/weft_server/src/shared/Navigation.res.js +++ b/packages/weft_server/src/shared/Navigation.res.js @@ -23,7 +23,8 @@ function make(client, render, url, onNavigateOpt, onFailed) { onNavigate: onNavigate, onFailed: onFailed, latest: Decoder$WeftServer.Latest.make(), - onServer: false + onServer: false, + rendering: undefined }; } @@ -35,7 +36,8 @@ function makeForServer(client, url) { onNavigate: param => {}, onFailed: undefined, latest: Decoder$WeftServer.Latest.make(), - onServer: true + onServer: true, + rendering: undefined }; } @@ -45,17 +47,47 @@ function checkOnClient(navigation) { } } -function wrap(navigation, element) { +function root(param) { + let onCommit = param.onCommit; + Hooks$Weft.useLayoutEffect1(() => { + Stdlib_Option.forEach(onCommit, onCommit => onCommit()); + }, [onCommit]); return JsxCore$Weft.jsx(provider, { - value: navigation, - children: element + value: param.navigation, + children: param.element + }); +} + +function wrapWith(navigation, element, onCommit) { + return JsxCore$Weft.jsx(root, { + navigation: navigation, + element: element, + onCommit: onCommit }); } +function wrap(navigation, element) { + return wrapWith(navigation, element, undefined); +} + function show(navigation, element) { navigation.render(wrap(navigation, element)); } +function showInTransition(navigation, element) { + return new Promise((resolve, param) => { + Stdlib_Option.forEach(navigation.rendering, replaced => replaced()); + let onCommit = () => { + if (navigation.rendering === onCommit) { + navigation.rendering = undefined; + } + resolve(); + }; + navigation.rendering = onCommit; + Transition$Weft.start(() => navigation.render(wrapWith(navigation, element, onCommit))); + }); +} + async function fetchAndShowOnClient(navigation, url, push, canFallBack) { let element; try { @@ -73,12 +105,12 @@ async function fetchAndShowOnClient(navigation, url, push, canFallBack) { if (element === undefined) { return; } - let element$1 = Primitive_option.valFromOption(element); navigation.url = url; - Transition$Weft.start(() => show(navigation, element$1)); + let shown = showInTransition(navigation, Primitive_option.valFromOption(element)); if (push) { - return navigation.onNavigate(url); + navigation.onNavigate(url); } + return await shown; } function fetchAndShow(navigation, url, push, canFallBack) { diff --git a/packages/weft_server/src/shared/Navigation.resi b/packages/weft_server/src/shared/Navigation.resi index 9d16bcc..3052ff9 100644 --- a/packages/weft_server/src/shared/Navigation.resi +++ b/packages/weft_server/src/shared/Navigation.resi @@ -65,10 +65,11 @@ let wrap: (t, Weft.Element.t) => Weft.Element.t let show: (t, Weft.Element.t) => unit /** -Fetches `url`'s payload and renders it in a transition, then calls `onNavigate`. Resolves once -it's been rendered, or straight away if a newer fetch replaced it. If the payload can't be fetched -or its root failed, the screen is left as it was, and `onFailed` is called (or, without it, the -promise rejects). +Fetches `url`'s payload, starts rendering it in a transition, and calls `onNavigate`. Resolves once +the new page is on screen (the transition has committed: a page still waiting on what it needs +before it can show takes as long as that takes), or as soon as a newer fetch or page replaces it. +If the payload can't be fetched or its root failed, the screen is left as it was, and `onFailed` is +called (or, without it, the promise rejects). */ let navigate: (t, string) => promise @@ -76,8 +77,9 @@ let navigate: (t, string) => promise let load: (t, string) => promise /** -Fetches what's on screen again. If that fails, it rejects, even with `onFailed`: the page's state -(what's been typed, say) stays, rather than being thrown away by a page load. +Fetches what's on screen again, and resolves once it's rendered, as `navigate` does. If that fails, +it rejects, even with `onFailed`: the page's state (what's been typed, say) stays, rather than being +thrown away by a page load. */ let refresh: t => promise diff --git a/packages/weft_server/tests/HydratedPage_test.res b/packages/weft_server/tests/HydratedPage_test.res index 8d4685c..e46df3c 100644 --- a/packages/weft_server/tests/HydratedPage_test.res +++ b/packages/weft_server/tests/HydratedPage_test.res @@ -145,4 +145,43 @@ describe("a server-rendered page", () => { loaded() t->expect(clicked("#root button"))->Expect.toBe("early: 1") }) + + testAsync( + "stops when its signal is aborted: its root unmounted, the history no longer followed", + async t => { + reset() + let assets = assets() + let html = await Testing.readAll( + Page.toStream( +

+ +
, + ~assets, + ), + ) + apply(html->String.slice(~start=html->String.indexOf("") + String.length(""))) + let controller = Streams.Controller.make() + let fetched = [] + let _ = await Bootstrap.start( + ~loadModule=assets.loadModule->Option.getUnsafe, + ~fetch=(url, ~signal as _) => { + fetched->Array.push(url) + Promise.make((_, _) => ()) + }, + ~container=rootOf(), + ~url="/", + ~signal=controller->Streams.Controller.signal, + (), + ) + await settle() + t->expect(clicked("#root button"))->Expect.toBe("stopped: 1") + WeftDom.Client.act(() => controller->Streams.Controller.abort) + t->expect(WeftDom.Dom.innerHTML(rootOf()))->Expect.toBe("") + // Going back or forward is another page's business now. + let popState: unit => unit = %raw(`() => window.dispatchEvent(new PopStateEvent("popstate"))`) + popState() + await settle() + t->expect(fetched)->Expect.toEqual([]) + }, + ) }) diff --git a/packages/weft_server/tests/HydratedPage_test.res.js b/packages/weft_server/tests/HydratedPage_test.res.js index 022f644..63b0420 100644 --- a/packages/weft_server/tests/HydratedPage_test.res.js +++ b/packages/weft_server/tests/HydratedPage_test.res.js @@ -116,7 +116,7 @@ Vitest.describe("a server-rendered page", undefined, undefined, undefined, undef Stdlib_JsError.throwWithMessage("the page ended early"); } let fromServer = allElements(); - await Bootstrap$WeftServer.start(assets.loadModule, undefined, Primitive_option.some(rootOf()), "/", undefined, undefined); + await Bootstrap$WeftServer.start(assets.loadModule, undefined, Primitive_option.some(rootOf()), "/", undefined, undefined, undefined); await settle(); t.expect(sameElements(fromServer, allElements())).toBe(true); t.expect(clicked("#outer button")).toBe("outer: 1"); @@ -152,6 +152,32 @@ Vitest.describe("a server-rendered page", undefined, undefined, undefined, undef loaded(); return t.expect(clicked("#root button")).toBe("early: 1"); }); + Vitest.testAsync("stops when its signal is aborted: its root unmounted, the history no longer followed", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, async t => { + reset(); + let assets = PageSupport$WeftServer.assets(); + let html = await Testing$WeftServer.readAll(Page$WeftServer.toStream(Jsx$WeftDom.Elements.jsx("main", { + children: Jsx$WeftDom.jsx(PageSupport$WeftServer.Counter.make, { + label: "stopped" + }) + }), assets, undefined, undefined, undefined, undefined, undefined, undefined)); + apply(html.slice(html.indexOf("") + "".length | 0)); + let controller = new AbortController(); + let fetched = []; + await Bootstrap$WeftServer.start(assets.loadModule, (url, param) => { + fetched.push(url); + return new Promise((param, param$1) => {}); + }, Primitive_option.some(rootOf()), "/", undefined, Primitive_option.some(controller.signal), undefined); + await settle(); + t.expect(clicked("#root button")).toBe("stopped: 1"); + Client$WeftDom.act(() => { + controller.abort(); + }); + t.expect(rootOf().innerHTML).toBe(""); + let popState = (() => window.dispatchEvent(new PopStateEvent("popstate"))); + popState(); + await settle(); + return t.expect(fetched).toEqual([]); + }); }); export { diff --git a/packages/weft_server/tests/Navigation_test.res b/packages/weft_server/tests/Navigation_test.res index bf1bb2d..45b604e 100644 --- a/packages/weft_server/tests/Navigation_test.res +++ b/packages/weft_server/tests/Navigation_test.res @@ -17,6 +17,15 @@ let textIn = (renderer, id) => node->TestRenderer.children->Array.map(TestRenderer.text)->Array.join("") ) +/** +Waits for `promise` (a `navigate`'s, say, which resolves once its page has committed) while the +test's scheduler renders what's pending. +*/ +let andSettle = async promise => { + await settle() + await promise +} + /** A page on `renderer`, its client fetching from `fetch`, started at `url` (rendered at once). */ let start = async (~fetch, ~route, ~url, ~onNavigate=?, ~onFailed=?) => { let renderer = TestRenderer.create() @@ -86,8 +95,7 @@ describe("navigating", () => { ) bump("outside") let titleNode = uid(renderer, "title") - await navigation->Navigation.navigate("/b") - await settle() + await navigation->Navigation.navigate("/b")->andSettle t->expect(textIn(renderer, "title"))->Expect.toEqual(Some("b")) t->expect(shows(renderer)->String.includes("outside:1"))->Expect.toBe(true) t->expect(uid(renderer, "title"))->Expect.toEqual(titleNode) @@ -97,6 +105,33 @@ describe("navigating", () => { }, ) + testAsync("resolves once the new page is on screen, not when it starts rendering", async t => { + reset() + // A page reading a promise the server sends later, outside every boundary: it can't show + // until that has come. + let gate = Deferred.make() + let route = url => + switch url { + | "/reading" => +
+ Promise.thenResolve(() => "read")} /> +
+ | _ => route(url) + } + let (fetch, _) = serve(route) + let (renderer, navigation) = await start(~fetch, ~route, ~url="/a") + let navigated = ref(false) + let navigating = + navigation->Navigation.navigate("/reading")->Promise.thenResolve(() => navigated := true) + await settle() + // Rendering, but not on screen: the old page is, and the navigation hasn't resolved. + t->expect(textIn(renderer, "title"))->Expect.toEqual(Some("a")) + t->expect(navigated.contents)->Expect.toBe(false) + gate.resolve() + await navigating->andSettle + t->expect(textIn(renderer, "read"))->Expect.toEqual(Some("read")) + }) + testAsync("refresh fetches the current page again, without recording a URL", async t => { reset() let navigated = [] @@ -108,8 +143,7 @@ describe("navigating", () => { ~onNavigate=url => navigated->Array.push(url), ) titles->Dict.set("a", "A, renamed") - await navigation->Navigation.refresh - await settle() + await navigation->Navigation.refresh->andSettle t->expect(shows(renderer)->String.includes("A, renamed"))->Expect.toBe(true) t->expect(navigated)->Expect.toEqual([]) t->expect(served->Array.map(request => request.url))->Expect.toEqual(["/a"]) @@ -124,8 +158,7 @@ describe("navigating", () => { let slow = navigation->Navigation.navigate("/slow") await settle() let fast = navigation->Navigation.navigate("/fast") - await fast - await settle() + await fast->andSettle served->Array.forEach(request => Testing.deliverAll(request.pipe)) await slow await settle() @@ -154,8 +187,7 @@ describe("navigating", () => { } let (fetch, served) = serve(~delivery=url => url == "/next" ? Manual : Immediate, route) let (renderer, navigation) = await start(~fetch, ~route, ~url="/a") - await navigation->Navigation.navigate("/streaming") - await settle() + await navigation->Navigation.navigate("/streaming")->andSettle t->expect(shows(renderer)->String.includes(`id="loading"`))->Expect.toBe(true) // The next navigation cancels the streaming page's payload while it's still on screen. let next = navigation->Navigation.navigate("/next") @@ -164,8 +196,7 @@ describe("navigating", () => { t->expect(shows(renderer)->String.includes(`id="loading"`))->Expect.toBe(true) gate.resolve() served->Array.forEach(request => Testing.deliverAll(request.pipe)) - await next - await settle() + await next->andSettle t->expect(shows(renderer)->String.includes(`id="title">next`))->Expect.toBe(true) t->expect(shows(renderer)->String.includes("late"))->Expect.toBe(false) }, @@ -367,8 +398,7 @@ describe("part of a page, refetched (useRefetchable)", () => { await settle() t->expect(shows(renderer)->String.includes("refetched"))->Expect.toBe(true) comments->Dict.set("a", "refreshed") - await navigation->Navigation.refresh - await settle() + await navigation->Navigation.refresh->andSettle t->expect(shows(renderer)->String.includes("refreshed"))->Expect.toBe(true) comments->Dict.set("a", "refetched again") await refetch("/comments/a") diff --git a/packages/weft_server/tests/Navigation_test.res.js b/packages/weft_server/tests/Navigation_test.res.js index e0f5e27..8ecd034 100644 --- a/packages/weft_server/tests/Navigation_test.res.js +++ b/packages/weft_server/tests/Navigation_test.res.js @@ -21,6 +21,11 @@ function textIn(renderer, id) { return Stdlib_Option.map(TestRenderer$Weft.findById(renderer, id), node => TestRenderer$Weft.children(node).map(TestRenderer$Weft.text).join("")); } +async function andSettle(promise) { + await Support$WeftServer.settle(); + return await promise; +} + async function start(fetch, route, url, onNavigate, onFailed) { let renderer = TestRenderer$Weft.create(); let client = Decoder$WeftServer.make(Testing$WeftServer.loadModule(Support$WeftServer.makeModules()), fetch); @@ -119,8 +124,7 @@ Vitest.describe("navigating", undefined, undefined, undefined, undefined, undefi let renderer = match$1[0]; Support$WeftServer.bump("outside"); let titleNode = uid(renderer, "title"); - await Navigation$WeftServer.navigate(navigation, "/b"); - await Support$WeftServer.settle(); + await andSettle(Navigation$WeftServer.navigate(navigation, "/b")); t.expect(textIn(renderer, "title")).toEqual("b"); t.expect(TestRenderer$Weft.toString(renderer).includes("outside:1")).toBe(true); t.expect(uid(renderer, "title")).toEqual(titleNode); @@ -128,6 +132,38 @@ Vitest.describe("navigating", undefined, undefined, undefined, undefined, undefi t.expect(Navigation$WeftServer.url(navigation)).toBe("/b"); return t.expect(match[1].map(request => request.url)).toEqual(["/b"]); }); + Vitest.testAsync("resolves once the new page is on screen, not when it starts rendering", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, async t => { + reset(); + let gate = Deferred$Weft.make(); + let route$1 = url => { + if (url === "/reading") { + return TestJsx$Weft.Elements.jsx("section", { + id: "reading-page", + children: TestJsx$Weft.jsx(Support$WeftServer.Reader.make, { + id: "read", + promise: gate.promise.then(() => "read") + }) + }); + } else { + return route(url); + } + }; + let match = Support$WeftServer.serve(undefined, route$1); + let match$1 = await start(match[0], route$1, "/a", undefined, undefined); + let renderer = match$1[0]; + let navigated = { + contents: false + }; + let navigating = Navigation$WeftServer.navigate(match$1[1], "/reading").then(() => { + navigated.contents = true; + }); + await Support$WeftServer.settle(); + t.expect(textIn(renderer, "title")).toEqual("a"); + t.expect(navigated.contents).toBe(false); + gate.resolve(); + await andSettle(navigating); + return t.expect(textIn(renderer, "read")).toEqual("read"); + }); Vitest.testAsync("refresh fetches the current page again, without recording a URL", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, async t => { reset(); let navigated = []; @@ -136,8 +172,7 @@ Vitest.describe("navigating", undefined, undefined, undefined, undefined, undefi navigated.push(url); }, undefined); titles["a"] = "A, renamed"; - await Navigation$WeftServer.refresh(match$1[1]); - await Support$WeftServer.settle(); + await andSettle(Navigation$WeftServer.refresh(match$1[1])); t.expect(TestRenderer$Weft.toString(match$1[0]).includes("A, renamed")).toBe(true); t.expect(navigated).toEqual([]); return t.expect(match[1].map(request => request.url)).toEqual(["/a"]); @@ -157,8 +192,7 @@ Vitest.describe("navigating", undefined, undefined, undefined, undefined, undefi let slow = Navigation$WeftServer.navigate(navigation, "/slow"); await Support$WeftServer.settle(); let fast = Navigation$WeftServer.navigate(navigation, "/fast"); - await fast; - await Support$WeftServer.settle(); + await andSettle(fast); served.forEach(request => Testing$WeftServer.deliverAll(request.pipe)); await slow; await Support$WeftServer.settle(); @@ -199,8 +233,7 @@ Vitest.describe("navigating", undefined, undefined, undefined, undefined, undefi let match$1 = await start(match[0], route$1, "/a", undefined, undefined); let navigation = match$1[1]; let renderer = match$1[0]; - await Navigation$WeftServer.navigate(navigation, "/streaming"); - await Support$WeftServer.settle(); + await andSettle(Navigation$WeftServer.navigate(navigation, "/streaming")); t.expect(TestRenderer$Weft.toString(renderer).includes(`id="loading"`)).toBe(true); let next = Navigation$WeftServer.navigate(navigation, "/next"); await Support$WeftServer.settle(); @@ -208,8 +241,7 @@ Vitest.describe("navigating", undefined, undefined, undefined, undefined, undefi t.expect(TestRenderer$Weft.toString(renderer).includes(`id="loading"`)).toBe(true); gate.resolve(); served.forEach(request => Testing$WeftServer.deliverAll(request.pipe)); - await next; - await Support$WeftServer.settle(); + await andSettle(next); t.expect(TestRenderer$Weft.toString(renderer).includes(`id="title">next`)).toBe(true); return t.expect(TestRenderer$Weft.toString(renderer).includes("late")).toBe(false); }); @@ -457,8 +489,7 @@ Vitest.describe("part of a page, refetched (useRefetchable)", undefined, undefin await Support$WeftServer.settle(); t.expect(TestRenderer$Weft.toString(renderer).includes("refetched")).toBe(true); comments["a"] = "refreshed"; - await Navigation$WeftServer.refresh(match$1[1]); - await Support$WeftServer.settle(); + await andSettle(Navigation$WeftServer.refresh(match$1[1])); t.expect(TestRenderer$Weft.toString(renderer).includes("refreshed")).toBe(true); comments["a"] = "refetched again"; await Support$WeftServer.refetchers["/comments/a"](undefined); @@ -595,6 +626,7 @@ export { shows, uid, textIn, + andSettle, start, titles, comments, diff --git a/packages/weft_signals/src/SignalHooks.res b/packages/weft_signals/src/SignalHooks.res index 412dc22..479c9bc 100644 --- a/packages/weft_signals/src/SignalHooks.res +++ b/packages/weft_signals/src/SignalHooks.res @@ -24,7 +24,8 @@ let subscribe = (signal: Signal.t<'value, 'diff>, onChange: unit => unit) => { let useSignal = (signal: Signal.t<'value, 'diff>): 'value => { let subscribe = Hooks.useCallback1(onChange => subscribe(signal, onChange), [signal]) let getSnapshot = Hooks.useCallback1(() => Signal.peek(signal), [signal]) - Hooks.useSyncExternalStore(~subscribe, ~getSnapshot) + // On the server (an entrypoint rendered to HTML), the signal's value there: what the HTML shows. + Hooks.useSyncExternalStore(~subscribe, ~getSnapshot, ~getServerSnapshot=getSnapshot) } let useAtom = (atom: Atom.t<'value, 'diff>) => { diff --git a/packages/weft_signals/src/SignalHooks.res.js b/packages/weft_signals/src/SignalHooks.res.js index 01327c1..a57cb99 100644 --- a/packages/weft_signals/src/SignalHooks.res.js +++ b/packages/weft_signals/src/SignalHooks.res.js @@ -24,7 +24,7 @@ function useSignal(signal) { return () => EffectBase$Signals.dispose(effect); }, [signal]); let getSnapshot = Hooks$Weft.useCallback1(() => Signal$Signals.peek(signal), [signal]); - return Hooks$Weft.useSyncExternalStore(subscribe, getSnapshot, undefined); + return Hooks$Weft.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } function useAtom(atom) { diff --git a/packages/weft_signals/src/SignalHooks.resi b/packages/weft_signals/src/SignalHooks.resi index dabc514..3ca0a0d 100644 --- a/packages/weft_signals/src/SignalHooks.resi +++ b/packages/weft_signals/src/SignalHooks.resi @@ -22,6 +22,11 @@ together with everything else updated in the same task. A render only reads the They're built on `Weft.Hooks.useSyncExternalStore`: a signal that changes while a transition is paused partway can't commit with some components showing its old value and some its new one. +On the server (`Weft.ServerRender.renderComponent`, which renders entrypoints to HTML), each hook +gives the signal's value there and subscribes to nothing; the client's first render, hydrating that +HTML, reads its own. A signal that differs between the two (one only the browser writes, say) makes +a mismatch, as any external store's would: give it the same initial value on both sides. + Read signals with these hooks, not `Signal.get` in a component's body: a component only renders again for the signals a hook subscribed to. diff --git a/packages/weft_signals/tests/SignalHooks_test.res b/packages/weft_signals/tests/SignalHooks_test.res index b0d456c..ca7dd9a 100644 --- a/packages/weft_signals/tests/SignalHooks_test.res +++ b/packages/weft_signals/tests/SignalHooks_test.res @@ -177,3 +177,34 @@ describe("useSignal and useComputed", () => { ->Expect.toBe(`222`) }) }) + +describe("on the server", () => { + test( + "an entrypoint rendered to HTML shows the signals' values, and hydrates to follow them", + t => { + let count = Atom.make(3) + let parity = Signals.Computed.make(() => mod(Atom.get(count), 2) == 0 ? "even" : "odd") + module Counter = { + @weft.component + let make = () => { + let (value, update) = SignalHooks.useAtom(count) + let label = SignalHooks.useSignal(parity) + let doubled = SignalHooks.useComputed0(() => Atom.get(count) * 2) + + } + } + // What a server renderer does with an entrypoint: call it, outside any tree. + let element = Weft.ServerRender.renderComponent(Counter.make, {}, {contexts: []}) + let html = TestRenderer.create() + TestRenderer.render(html, element) + t->expect(textOf(html))->Expect.toBe(``) + // The browser, hydrating that HTML, then following the atom. + let renderer = TestRenderer.hydrate(textOf(html), ) + t->expect(textOf(renderer))->Expect.toBe(``) + TestRenderer.click(renderer, "count") + t->expect(textOf(renderer))->Expect.toBe(``) + }, + ) +}) diff --git a/packages/weft_signals/tests/SignalHooks_test.res.js b/packages/weft_signals/tests/SignalHooks_test.res.js index 0465023..347d499 100644 --- a/packages/weft_signals/tests/SignalHooks_test.res.js +++ b/packages/weft_signals/tests/SignalHooks_test.res.js @@ -6,6 +6,7 @@ import * as TestJsx$Weft from "@e-cline/weft/src/testing/TestJsx.res.js"; import * as Batch$Signals from "@e-cline/signals/src/Batch.res.js"; import * as Transition$Weft from "@e-cline/weft/src/shared/Transition.res.js"; import * as Computed$Signals from "@e-cline/signals/src/Computed.res.js"; +import * as ServerRender$Weft from "@e-cline/weft/src/shared/ServerRender.res.js"; import * as TestRenderer$Weft from "@e-cline/weft/src/testing/TestRenderer.res.js"; import * as TestScheduler$Weft from "@e-cline/weft/src/testing/TestScheduler.res.js"; import * as SignalHooks$WeftSignals from "../src/SignalHooks.res.js"; @@ -201,6 +202,38 @@ Vitest.describe("useSignal and useComputed", undefined, undefined, undefined, un }); }); +Vitest.describe("on the server", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, () => Vitest.test("an entrypoint rendered to HTML shows the signals' values, and hydrates to follow them", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, t => { + let count = Atom$Signals.make(undefined, undefined, 3); + let parity = Computed$Signals.make(undefined, undefined, () => { + if (Atom$Signals.get(count) % 2 === 0) { + return "even"; + } else { + return "odd"; + } + }); + let SignalHooks_test = props => { + let match = SignalHooks$WeftSignals.useAtom(count); + let update = match[1]; + let label = SignalHooks$WeftSignals.useSignal(parity); + let doubled = SignalHooks$WeftSignals.useComputed0(undefined, () => (Atom$Signals.get(count) << 1)); + return TestJsx$Weft.Elements.jsx("button", { + id: "count", + onClick: () => update(n => n + 1 | 0), + children: match[0].toString() + ` ` + label + ` ` + doubled.toString() + }); + }; + let element = ServerRender$Weft.renderComponent(SignalHooks_test, {}, { + contexts: [] + }); + let html = TestRenderer$Weft.create(); + TestRenderer$Weft.render(html, element); + t.expect(TestRenderer$Weft.toString(html)).toBe(``); + let renderer = TestRenderer$Weft.hydrate(TestRenderer$Weft.toString(html), TestJsx$Weft.jsx(SignalHooks_test, {})); + t.expect(TestRenderer$Weft.toString(renderer)).toBe(``); + TestRenderer$Weft.click(renderer, "count"); + t.expect(TestRenderer$Weft.toString(renderer)).toBe(``); +})); + let Atom; let Batch; diff --git a/tools/weft_vite_plugin/README.md b/tools/weft_vite_plugin/README.md index b8d4400..61298d0 100644 --- a/tools/weft_vite_plugin/README.md +++ b/tools/weft_vite_plugin/README.md @@ -131,11 +131,18 @@ await page->WeftTesting.navigate("/notes/2") `~bootstrap=false` (then `startBootstrap`) opens a page as if the bootstrap's chunk hadn't loaded. `rewriteBodies` changes requests on their way to the server, to play a client older or newer than it (an RPC's request with other fields), or a forged one. +- **Cookies** are kept as a browser keeps them for the app's origin: what the server's `Set-Cookie` + headers set is sent with every request after, until it expires; `cookies` reads them, and + `clearCookies` makes the next request a new visitor's. +- **One page is open at a time**: opening another closes it (`closePage` does too), unmounting its + root, so its effects are cleaned up (their intervals and subscriptions with them) and it stops + following the history, as leaving a page in a browser would. - **`Build` mode** runs the build: the worker imports `dist/server/index.js`, and the page is the built client's own bootstrap and chunks. A file can open one page that way (the build's modules are shared), and moves on by following links. -[`examples/weft_notes`](../../examples/weft_notes) is tested this way, and +[`examples/weft_notes`](../../examples/weft_notes) and +[`examples/weft_shop`](../../examples/weft_shop) are tested this way, and [`examples/weft_split`](../../examples/weft_split) in both modes. ## Testing the plugin diff --git a/tools/weft_vite_plugin/src/WeftTesting.res b/tools/weft_vite_plugin/src/WeftTesting.res index 031ab79..f819fc3 100644 --- a/tools/weft_vite_plugin/src/WeftTesting.res +++ b/tools/weft_vite_plugin/src/WeftTesting.res @@ -21,6 +21,8 @@ let start = (~root, ~mode=Source, ~buildDir=?) => @get external requests: app => array = "requests" @send external rewriteBodies: (app, string, string => string) => unit = "rewriteBodies" @send external stopRewriting: app => unit = "stopRewriting" +@send external cookies: app => dict = "cookies" +@send external clearCookies: app => unit = "clearCookies" @get external pageRequests: page => array = "requests" type delivery = Immediate | Manual | EveryBytes(int) @@ -39,6 +41,7 @@ type startClient = { fetch: (string, WeftServer.Decoder.signal) => promise, container: WeftDom.Dom.element, url: string, + signal: WeftServer.Rpc.signal, } type clientSide = { @@ -64,12 +67,13 @@ external loadModule: WeftServer.Decoder.loadModule = "loadModule" let client = { loadModule: id => loadModule(id), - start: ({loadModule, fetch, container, url}) => + start: ({loadModule, fetch, container, url, signal}) => WeftServer.Bootstrap.start( ~loadModule, ~fetch=(url, ~signal) => fetch(url, signal), ~container, ~url, + ~signal, (), ), act: WeftDom.Client.act, @@ -79,6 +83,7 @@ let open_ = (app, ~container, ~delivery=Immediate, ~bootstrap=true, url) => openPage(app, {url, container, delivery: toJs(delivery), client, bootstrap}) @send external startBootstrap: page => unit = "startBootstrap" +@send external closePage: page => unit = "close" @send external hydratedJs: (page, Nullable.t) => promise = "hydrated" let hydrated = (page, ~timeout=?) => hydratedJs(page, Nullable.fromOption(timeout)) @get external warnings: page => array = "warnings" diff --git a/tools/weft_vite_plugin/src/WeftTesting.res.js b/tools/weft_vite_plugin/src/WeftTesting.res.js index 7fd946f..2a33121 100644 --- a/tools/weft_vite_plugin/src/WeftTesting.res.js +++ b/tools/weft_vite_plugin/src/WeftTesting.res.js @@ -46,7 +46,7 @@ function client_loadModule(id) { function client_start(param) { let fetch = param.fetch; - return Bootstrap$WeftServer.start(param.loadModule, (url, signal) => fetch(url, signal), Primitive_option.some(param.container), param.url, undefined, undefined); + return Bootstrap$WeftServer.start(param.loadModule, (url, signal) => fetch(url, signal), Primitive_option.some(param.container), param.url, undefined, Primitive_option.some(param.signal), undefined); } let client = { @@ -153,10 +153,22 @@ function stopRewriting(prim) { prim.stopRewriting(); } +function cookies(prim) { + return prim.cookies(); +} + +function clearCookies(prim) { + prim.clearCookies(); +} + function startBootstrap(prim) { prim.startBootstrap(); } +function closePage(prim) { + prim.close(); +} + function warnings(prim) { return prim.warnings; } @@ -216,8 +228,11 @@ export { requests, rewriteBodies, stopRewriting, + cookies, + clearCookies, open_, startBootstrap, + closePage, hydrated, warnings, pageRequests, diff --git a/tools/weft_vite_plugin/src/WeftTesting.resi b/tools/weft_vite_plugin/src/WeftTesting.resi index 686e1be..31be7ea 100644 --- a/tools/weft_vite_plugin/src/WeftTesting.resi +++ b/tools/weft_vite_plugin/src/WeftTesting.resi @@ -47,6 +47,15 @@ let rewriteBodies: (app, string, string => string) => unit let stopRewriting: app => unit +/** +The cookies the client has, by name: what the server's `Set-Cookie` headers set (and haven't +expired), sent with every request after, as a browser sends its cookies for the app's origin. +*/ +let cookies: app => dict + +/** Forgets every cookie: the next request is a new visitor's. */ +let clearCookies: app => unit + /** How the server's responses reach the client. */ type delivery = /** As they're sent. */ @@ -82,6 +91,13 @@ let open_: ( /** Starts the bootstrap of a page opened with `~bootstrap=false`: now, or once the shell is in. */ let startBootstrap: page => unit +/** +Closes the page (`Source` mode), as leaving it would: its root is unmounted, so its effects are +cleaned up (their intervals and subscriptions with them), and it stops following the history. +Opening another page closes the one before, so a page left open by one test can't act in the next. +*/ +let closePage: page => unit + /** Waits until the bootstrap has hydrated the page (or rendered it, if there was no HTML): it has started, the entrypoints it needed have loaded (but those `holdLoads` holds), and the renders after diff --git a/tools/weft_vite_plugin/src/testing.ts b/tools/weft_vite_plugin/src/testing.ts index 25a4876..6b6a75c 100644 --- a/tools/weft_vite_plugin/src/testing.ts +++ b/tools/weft_vite_plugin/src/testing.ts @@ -91,6 +91,11 @@ export class App { delivery: Delivery = {type: 'immediate'}; /** Every request made, by URL, in order. */ readonly requests: string[] = []; + /** + * The cookies the server has set, by name, as a browser keeps them for the app's origin: sent + * with every request after, until they expire or `clearCookies`. + */ + private readonly jar = new Map(); /** How `rewriteBodies` changes the bodies of requests to paths containing `matching`. */ private rewrite: | {matching: string; change: (body: string) => string} @@ -131,6 +136,39 @@ export class App { this.rewrite = undefined; } + /** The cookies the client has, by name (what the server's `Set-Cookie` headers left). */ + cookies(): Record { + return Object.fromEntries(this.jar); + } + + /** Forgets every cookie, as a new visitor (or a cleared browser) would. */ + clearCookies() { + this.jar.clear(); + } + + /** Keeps (or, for one that has expired, forgets) the cookie a `Set-Cookie` header sets. */ + private keepCookie(header: string) { + const [pair = '', ...attributes] = header.split(';'); + const equals = pair.indexOf('='); + if (equals <= 0) return; + const name = pair.slice(0, equals).trim(); + const value = pair.slice(equals + 1).trim(); + const expired = attributes.some((attribute) => { + const [key = '', setting = ''] = attribute + .split('=') + .map((part) => part.trim()); + return ( + (key.toLowerCase() === 'max-age' && Number(setting) <= 0) || + (key.toLowerCase() === 'expires' && Date.parse(setting) <= Date.now()) + ); + }); + if (expired) { + this.jar.delete(name); + } else { + this.jar.set(name, value); + } + } + /** Calls `listener` whenever a chunk arrives, is delivered, or a response ends. */ onChange(listener: () => void): () => void { this.listeners.add(listener); @@ -146,6 +184,9 @@ export class App { const exchange = this.exchanges.get(message.id); const waiting = this.responses.get(message.id); this.responses.delete(message.id); + for (const [name, value] of message.headers) { + if (name.toLowerCase() === 'set-cookie') this.keepCookie(value); + } if (exchange === undefined || waiting === undefined) break; const body = new ReadableStream({ start: (controller) => { @@ -319,12 +360,20 @@ export class App { this.responses.get(id)?.reject(new DOMException('aborted', 'AbortError')); this.responses.delete(id); }); + const headers = [...request.headers]; + // The browser's cookies for the app, unless the request brought its own. + if (this.jar.size > 0 && !request.headers.has('cookie')) { + headers.push([ + 'cookie', + [...this.jar].map(([name, value]) => `${name}=${value}`).join('; '), + ]); + } this.post({ type: 'request', id, method: request.method, url: request.url, - headers: [...request.headers], + headers, body, }); return response; @@ -680,6 +729,8 @@ export type ClientSide = { ) => Promise>; container: Element; url: string; + /** Aborted when the page closes: the bootstrap stops it (unmounts its root). */ + signal: AbortSignal; }) => Promise; /** `WeftDom.Client.act`: runs `f`, then renders and runs effects. */ act: (f: () => void) => void; @@ -766,6 +817,8 @@ export class Page { private wantsBootstrap: boolean; private bootstrapped = false; private resolveStarted!: (started: Promise) => void; + /** Aborted by `close`. */ + readonly closing = new AbortController(); constructor( app: App, @@ -838,6 +891,17 @@ export class Page { this.resolveStarted(this.start(this)); } + /** + * Closes the page, as leaving it would: its root is unmounted (its effects cleaned up, so its + * intervals and subscriptions stop) and it stops following the history. Opening another page + * closes the one before. + */ + close() { + if (this.closing.signal.aborted) return; + this.act(() => this.closing.abort()); + if (openedLast === this) openedLast = undefined; + } + /** Runs the bootstrap (on a page opened without it), now or once `#root` arrives. */ startBootstrap() { this.wantsBootstrap = true; @@ -1031,6 +1095,9 @@ export class Page { let builtBootstrapImported = false; +/** The page opened last, which opening another closes. */ +let openedLast: Page | undefined; + /** * Opens `url` in `container`: requests its HTML through the server and applies it as the browser's * parser would, as it arrives (the shell into the container, then each inline script and segment, in @@ -1041,6 +1108,7 @@ let builtBootstrapImported = false; * id, so one page is open in a document at a time. */ export function openPage(app: App, options: OpenOptions): Page { + openedLast?.close(); app.delivery = options.delivery ?? {type: 'immediate'}; const self = globalThis as {__weft?: unknown}; delete self.__weft; @@ -1075,6 +1143,7 @@ export function openPage(app: App, options: OpenOptions): Page { options.bootstrap ?? true, ); collecting = page.warnings; + openedLast = page; return page; }; if (app.mode === 'source') { @@ -1084,6 +1153,7 @@ export function openPage(app: App, options: OpenOptions): Page { fetch: payloadFetch, container, url: options.url, + signal: page.closing.signal, }), ); } diff --git a/tools/weft_vite_plugin/tests/fixtures/basic/src/Server.res b/tools/weft_vite_plugin/tests/fixtures/basic/src/Server.res index eb34615..929d4b4 100644 --- a/tools/weft_vite_plugin/tests/fixtures/basic/src/Server.res +++ b/tools/weft_vite_plugin/tests/fixtures/basic/src/Server.res @@ -1,7 +1,7 @@ /*** The server entry: a fetch handler (the default export), answering a page with HTML that loads the client's scripts from `virtual:weft/assets`, a payload request with a payload-shaped body, and -`/env` and `/assets.json` with what the tests check. +`/env`, `/assets.json` and `/cookies/...` with what the tests check. */ type assets = { @@ -18,6 +18,11 @@ let handle = async (request: Fetch.request) => { switch path { | "/env" => Fetch.json({"isDev": Common.Env.isDev}) | "/assets.json" => Fetch.json(assets) + | "/cookies/set" => + Fetch.makeResponse("", {headers: dict{"set-cookie": "flavour=oat; Path=/; HttpOnly"}}) + | "/cookies/expire" => + Fetch.makeResponse("", {headers: dict{"set-cookie": "flavour=; Path=/; Max-Age=0"}}) + | "/cookies/echo" => Fetch.json({"cookie": request->Fetch.headers->Fetch.header("cookie")}) | _ => switch request->Fetch.headers->Fetch.header("accept")->Nullable.toOption { | Some("text/x-weft-payload") => diff --git a/tools/weft_vite_plugin/tests/fixtures/basic/src/Server.res.js b/tools/weft_vite_plugin/tests/fixtures/basic/src/Server.res.js index a7bd213..4f5a550 100644 --- a/tools/weft_vite_plugin/tests/fixtures/basic/src/Server.res.js +++ b/tools/weft_vite_plugin/tests/fixtures/basic/src/Server.res.js @@ -12,6 +12,22 @@ async function handle(request) { switch (path) { case "/assets.json" : return Response.json(Assets); + case "/cookies/echo" : + return Response.json({ + cookie: request.headers.get("cookie") + }); + case "/cookies/expire" : + return new Response("", { + headers: { + "set-cookie": "flavour=; Path=/; Max-Age=0" + } + }); + case "/cookies/set" : + return new Response("", { + headers: { + "set-cookie": "flavour=oat; Path=/; HttpOnly" + } + }); case "/env" : return Response.json({ isDev: Env$Common.isDev diff --git a/tools/weft_vite_plugin/tests/harness.test.ts b/tools/weft_vite_plugin/tests/harness.test.ts index 34a4b99..75b87c7 100644 --- a/tools/weft_vite_plugin/tests/harness.test.ts +++ b/tools/weft_vite_plugin/tests/harness.test.ts @@ -48,6 +48,28 @@ describe('the harness, in source mode', () => { ).toEqual(['fetch', 'Response']); }); + test('keeps the cookies the server sets, sends them with later requests, and forgets expired ones', async () => { + const echo = async () => + ( + (await (await app.fetch('/cookies/echo')).json()) as { + cookie: string | null; + } + ).cookie; + expect(await echo()).toBe(null); + await app.fetch('/cookies/set'); + expect(app.cookies()).toEqual({flavour: 'oat'}); + expect(await echo()).toBe('flavour=oat'); + // A request that brings its own cookies sends those. + const own = await app.fetch('/cookies/echo', {headers: {cookie: 'mine=1'}}); + expect(((await own.json()) as {cookie: string}).cookie).toBe('mine=1'); + await app.fetch('/cookies/expire'); + expect(app.cookies()).toEqual({}); + expect(await echo()).toBe(null); + await app.fetch('/cookies/set'); + app.clearCookies(); + expect(await echo()).toBe(null); + }); + test('runs the server with the development conditions', async () => { expect(await (await app.fetch('/env')).json()).toEqual({isDev: true}); }); From e126d76a3383543b3685185d2d1a699d90ee9ba7 Mon Sep 17 00:00:00 2001 From: Ethan Cline <7142767+e-cline@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:02:43 -0400 Subject: [PATCH 2/3] Add weft_shop: a server-driven, streamed storefront example A larger example than weft_notes, built to exercise how Weft's features work together with the latency of real services in between: - Home and category pages are server-driven: a tree of CMS blocks kept as data, personalised per shopper and changed at runtime. Each block streams under its own boundary, inside a BlockFrame entrypoint (an error boundary with Retry, refetched from its own route, polled when live), and keeps its state when a refresh moves it. - Every slow call waits on Latency: seeded random delays and failures in dev and `vp run start`, gates the tests open in `manual` mode. - Catalog filters as navigations, "Load more" through an RPC returning server-rendered cards, search suggestions cancelling older requests, variant offers refetched by themselves, a cart in a signals store handed down by context, portals, cookie sessions through servy middleware, a three-step checkout and a self-refreshing order page. Tests: 45 scenario tests through the plugin's harness, and 16 Playwright tests against the build (one project with gated latency, one with seeded random latency and failures), now in CI's browser job. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 +- CONTRIBUTING.md | 5 +- README.md | 4 +- examples/weft_shop/README.md | 126 +++ examples/weft_shop/e2e/playwright.config.ts | 59 ++ examples/weft_shop/e2e/random.e2e.ts | 124 +++ examples/weft_shop/e2e/shop.e2e.ts | 292 ++++++ examples/weft_shop/package.json | 31 + examples/weft_shop/rescript.json | 26 + examples/weft_shop/serve.mjs | 13 + examples/weft_shop/src/client/AccountMenu.res | 35 + .../weft_shop/src/client/AccountMenu.res.js | 57 ++ examples/weft_shop/src/client/AddToCart.res | 63 ++ .../weft_shop/src/client/AddToCart.res.js | 93 ++ examples/weft_shop/src/client/BlockFrame.res | 66 ++ .../weft_shop/src/client/BlockFrame.res.js | 85 ++ examples/weft_shop/src/client/Carousel.res | 52 + examples/weft_shop/src/client/Carousel.res.js | 77 ++ examples/weft_shop/src/client/CartBadge.res | 58 ++ .../weft_shop/src/client/CartBadge.res.js | 92 ++ examples/weft_shop/src/client/CartLines.res | 91 ++ .../weft_shop/src/client/CartLines.res.js | 138 +++ .../weft_shop/src/client/CartSummaryFrame.res | 22 + .../src/client/CartSummaryFrame.res.js | 38 + .../weft_shop/src/client/CheckoutForm.res | 223 +++++ .../weft_shop/src/client/CheckoutForm.res.js | 450 +++++++++ examples/weft_shop/src/client/Countdown.res | 23 + .../weft_shop/src/client/Countdown.res.js | 42 + .../weft_shop/src/client/CurrencySelect.res | 43 + .../src/client/CurrencySelect.res.js | 59 ++ examples/weft_shop/src/client/FacetPanel.res | 202 ++++ .../weft_shop/src/client/FacetPanel.res.js | 258 +++++ examples/weft_shop/src/client/Gallery.res | 49 + examples/weft_shop/src/client/Gallery.res.js | 78 ++ examples/weft_shop/src/client/Link.res | 20 + examples/weft_shop/src/client/Link.res.js | 29 + examples/weft_shop/src/client/Newsletter.res | 46 + .../weft_shop/src/client/Newsletter.res.js | 79 ++ .../weft_shop/src/client/OnlineBanner.res | 31 + .../weft_shop/src/client/OnlineBanner.res.js | 34 + .../weft_shop/src/client/OrderTracker.res | 47 + .../weft_shop/src/client/OrderTracker.res.js | 71 ++ examples/weft_shop/src/client/Price.res | 20 + examples/weft_shop/src/client/Price.res.js | 43 + examples/weft_shop/src/client/ProductGrid.res | 86 ++ .../weft_shop/src/client/ProductGrid.res.js | 108 +++ .../weft_shop/src/client/ProductOffer.res | 51 + .../weft_shop/src/client/ProductOffer.res.js | 52 + examples/weft_shop/src/client/PromoForm.res | 40 + .../weft_shop/src/client/PromoForm.res.js | 71 ++ examples/weft_shop/src/client/ReviewList.res | 71 ++ .../weft_shop/src/client/ReviewList.res.js | 110 +++ examples/weft_shop/src/client/SearchBox.res | 104 ++ .../weft_shop/src/client/SearchBox.res.js | 139 +++ examples/weft_shop/src/client/ShopContext.res | 30 + .../weft_shop/src/client/ShopContext.res.js | 29 + examples/weft_shop/src/client/ShopShell.res | 49 + .../weft_shop/src/client/ShopShell.res.js | 61 ++ .../weft_shop/src/client/SignInDialog.res | 50 + .../weft_shop/src/client/SignInDialog.res.js | 65 ++ .../weft_shop/src/client/SignInFields.res | 48 + .../weft_shop/src/client/SignInFields.res.js | 74 ++ examples/weft_shop/src/client/SignInForm.res | 14 + .../weft_shop/src/client/SignInForm.res.js | 31 + examples/weft_shop/src/client/Store.res | 53 ++ examples/weft_shop/src/client/Store.res.js | 100 ++ examples/weft_shop/src/client/Tabs.res | 44 + examples/weft_shop/src/client/Tabs.res.js | 49 + examples/weft_shop/src/client/Toaster.res | 25 + examples/weft_shop/src/client/Toaster.res.js | 52 + .../weft_shop/src/client/WishlistButton.res | 43 + .../src/client/WishlistButton.res.js | 58 ++ examples/weft_shop/src/server/ApiServer.res | 179 ++++ .../weft_shop/src/server/ApiServer.res.js | 278 ++++++ examples/weft_shop/src/server/Blocks.res | 187 ++++ examples/weft_shop/src/server/Blocks.res.js | 309 ++++++ examples/weft_shop/src/server/Cms.res | 211 ++++ examples/weft_shop/src/server/Cms.res.js | 334 +++++++ examples/weft_shop/src/server/Control.res | 17 + examples/weft_shop/src/server/Control.res.js | 23 + examples/weft_shop/src/server/Db.res | 393 ++++++++ examples/weft_shop/src/server/Db.res.js | 719 ++++++++++++++ examples/weft_shop/src/server/Latency.res | 230 +++++ examples/weft_shop/src/server/Latency.res.js | 308 ++++++ examples/weft_shop/src/server/Pages.res | 473 +++++++++ examples/weft_shop/src/server/Pages.res.js | 901 ++++++++++++++++++ examples/weft_shop/src/server/Server.res | 226 +++++ examples/weft_shop/src/server/Server.res.js | 442 +++++++++ examples/weft_shop/src/server/Session.res | 158 +++ examples/weft_shop/src/server/Session.res.js | 191 ++++ examples/weft_shop/src/server/Styles.res | 107 +++ examples/weft_shop/src/server/Styles.res.js | 113 +++ examples/weft_shop/src/server/Ui.res | 131 +++ examples/weft_shop/src/server/Ui.res.js | 218 +++++ examples/weft_shop/src/shared/Api.res | 209 ++++ examples/weft_shop/src/shared/Api.res.js | 353 +++++++ examples/weft_shop/src/shared/Money.res | 55 ++ examples/weft_shop/src/shared/Money.res.js | 86 ++ examples/weft_shop/src/shared/Types.res | 105 ++ examples/weft_shop/src/shared/Types.res.js | 41 + examples/weft_shop/tests/Cart_test.res | 318 +++++++ examples/weft_shop/tests/Cart_test.res.js | 403 ++++++++ examples/weft_shop/tests/Catalog_test.res | 257 +++++ examples/weft_shop/tests/Catalog_test.res.js | 267 ++++++ examples/weft_shop/tests/Home_test.res | 384 ++++++++ examples/weft_shop/tests/Home_test.res.js | 375 ++++++++ examples/weft_shop/tests/Product_test.res | 249 +++++ examples/weft_shop/tests/Product_test.res.js | 222 +++++ examples/weft_shop/tests/ShopControl.res | 71 ++ examples/weft_shop/tests/ShopControl.res.js | 139 +++ examples/weft_shop/tests/ShopSupport.res | 273 ++++++ examples/weft_shop/tests/ShopSupport.res.js | 416 ++++++++ examples/weft_shop/vite.config.ts | 34 + package.json | 1 + pnpm-lock.yaml | 58 ++ rescript.json | 1 + 116 files changed, 15666 insertions(+), 6 deletions(-) create mode 100644 examples/weft_shop/README.md create mode 100644 examples/weft_shop/e2e/playwright.config.ts create mode 100644 examples/weft_shop/e2e/random.e2e.ts create mode 100644 examples/weft_shop/e2e/shop.e2e.ts create mode 100644 examples/weft_shop/package.json create mode 100644 examples/weft_shop/rescript.json create mode 100644 examples/weft_shop/serve.mjs create mode 100644 examples/weft_shop/src/client/AccountMenu.res create mode 100644 examples/weft_shop/src/client/AccountMenu.res.js create mode 100644 examples/weft_shop/src/client/AddToCart.res create mode 100644 examples/weft_shop/src/client/AddToCart.res.js create mode 100644 examples/weft_shop/src/client/BlockFrame.res create mode 100644 examples/weft_shop/src/client/BlockFrame.res.js create mode 100644 examples/weft_shop/src/client/Carousel.res create mode 100644 examples/weft_shop/src/client/Carousel.res.js create mode 100644 examples/weft_shop/src/client/CartBadge.res create mode 100644 examples/weft_shop/src/client/CartBadge.res.js create mode 100644 examples/weft_shop/src/client/CartLines.res create mode 100644 examples/weft_shop/src/client/CartLines.res.js create mode 100644 examples/weft_shop/src/client/CartSummaryFrame.res create mode 100644 examples/weft_shop/src/client/CartSummaryFrame.res.js create mode 100644 examples/weft_shop/src/client/CheckoutForm.res create mode 100644 examples/weft_shop/src/client/CheckoutForm.res.js create mode 100644 examples/weft_shop/src/client/Countdown.res create mode 100644 examples/weft_shop/src/client/Countdown.res.js create mode 100644 examples/weft_shop/src/client/CurrencySelect.res create mode 100644 examples/weft_shop/src/client/CurrencySelect.res.js create mode 100644 examples/weft_shop/src/client/FacetPanel.res create mode 100644 examples/weft_shop/src/client/FacetPanel.res.js create mode 100644 examples/weft_shop/src/client/Gallery.res create mode 100644 examples/weft_shop/src/client/Gallery.res.js create mode 100644 examples/weft_shop/src/client/Link.res create mode 100644 examples/weft_shop/src/client/Link.res.js create mode 100644 examples/weft_shop/src/client/Newsletter.res create mode 100644 examples/weft_shop/src/client/Newsletter.res.js create mode 100644 examples/weft_shop/src/client/OnlineBanner.res create mode 100644 examples/weft_shop/src/client/OnlineBanner.res.js create mode 100644 examples/weft_shop/src/client/OrderTracker.res create mode 100644 examples/weft_shop/src/client/OrderTracker.res.js create mode 100644 examples/weft_shop/src/client/Price.res create mode 100644 examples/weft_shop/src/client/Price.res.js create mode 100644 examples/weft_shop/src/client/ProductGrid.res create mode 100644 examples/weft_shop/src/client/ProductGrid.res.js create mode 100644 examples/weft_shop/src/client/ProductOffer.res create mode 100644 examples/weft_shop/src/client/ProductOffer.res.js create mode 100644 examples/weft_shop/src/client/PromoForm.res create mode 100644 examples/weft_shop/src/client/PromoForm.res.js create mode 100644 examples/weft_shop/src/client/ReviewList.res create mode 100644 examples/weft_shop/src/client/ReviewList.res.js create mode 100644 examples/weft_shop/src/client/SearchBox.res create mode 100644 examples/weft_shop/src/client/SearchBox.res.js create mode 100644 examples/weft_shop/src/client/ShopContext.res create mode 100644 examples/weft_shop/src/client/ShopContext.res.js create mode 100644 examples/weft_shop/src/client/ShopShell.res create mode 100644 examples/weft_shop/src/client/ShopShell.res.js create mode 100644 examples/weft_shop/src/client/SignInDialog.res create mode 100644 examples/weft_shop/src/client/SignInDialog.res.js create mode 100644 examples/weft_shop/src/client/SignInFields.res create mode 100644 examples/weft_shop/src/client/SignInFields.res.js create mode 100644 examples/weft_shop/src/client/SignInForm.res create mode 100644 examples/weft_shop/src/client/SignInForm.res.js create mode 100644 examples/weft_shop/src/client/Store.res create mode 100644 examples/weft_shop/src/client/Store.res.js create mode 100644 examples/weft_shop/src/client/Tabs.res create mode 100644 examples/weft_shop/src/client/Tabs.res.js create mode 100644 examples/weft_shop/src/client/Toaster.res create mode 100644 examples/weft_shop/src/client/Toaster.res.js create mode 100644 examples/weft_shop/src/client/WishlistButton.res create mode 100644 examples/weft_shop/src/client/WishlistButton.res.js create mode 100644 examples/weft_shop/src/server/ApiServer.res create mode 100644 examples/weft_shop/src/server/ApiServer.res.js create mode 100644 examples/weft_shop/src/server/Blocks.res create mode 100644 examples/weft_shop/src/server/Blocks.res.js create mode 100644 examples/weft_shop/src/server/Cms.res create mode 100644 examples/weft_shop/src/server/Cms.res.js create mode 100644 examples/weft_shop/src/server/Control.res create mode 100644 examples/weft_shop/src/server/Control.res.js create mode 100644 examples/weft_shop/src/server/Db.res create mode 100644 examples/weft_shop/src/server/Db.res.js create mode 100644 examples/weft_shop/src/server/Latency.res create mode 100644 examples/weft_shop/src/server/Latency.res.js create mode 100644 examples/weft_shop/src/server/Pages.res create mode 100644 examples/weft_shop/src/server/Pages.res.js create mode 100644 examples/weft_shop/src/server/Server.res create mode 100644 examples/weft_shop/src/server/Server.res.js create mode 100644 examples/weft_shop/src/server/Session.res create mode 100644 examples/weft_shop/src/server/Session.res.js create mode 100644 examples/weft_shop/src/server/Styles.res create mode 100644 examples/weft_shop/src/server/Styles.res.js create mode 100644 examples/weft_shop/src/server/Ui.res create mode 100644 examples/weft_shop/src/server/Ui.res.js create mode 100644 examples/weft_shop/src/shared/Api.res create mode 100644 examples/weft_shop/src/shared/Api.res.js create mode 100644 examples/weft_shop/src/shared/Money.res create mode 100644 examples/weft_shop/src/shared/Money.res.js create mode 100644 examples/weft_shop/src/shared/Types.res create mode 100644 examples/weft_shop/src/shared/Types.res.js create mode 100644 examples/weft_shop/tests/Cart_test.res create mode 100644 examples/weft_shop/tests/Cart_test.res.js create mode 100644 examples/weft_shop/tests/Catalog_test.res create mode 100644 examples/weft_shop/tests/Catalog_test.res.js create mode 100644 examples/weft_shop/tests/Home_test.res create mode 100644 examples/weft_shop/tests/Home_test.res.js create mode 100644 examples/weft_shop/tests/Product_test.res create mode 100644 examples/weft_shop/tests/Product_test.res.js create mode 100644 examples/weft_shop/tests/ShopControl.res create mode 100644 examples/weft_shop/tests/ShopControl.res.js create mode 100644 examples/weft_shop/tests/ShopSupport.res create mode 100644 examples/weft_shop/tests/ShopSupport.res.js create mode 100644 examples/weft_shop/vite.config.ts 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..5ed5583 --- /dev/null +++ b/examples/weft_shop/README.md @@ -0,0 +1,126 @@ +# 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 account page; 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`), 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; signing in for the account page, and out; 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; 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..d31ab3d --- /dev/null +++ b/examples/weft_shop/e2e/shop.e2e.ts @@ -0,0 +1,292 @@ +// 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('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) +
+ {switch user { + | Some(name) => + <> + {WeftDom.Jsx.string(`Hi, ${name}`)} + + + | None => + + }} +
+} diff --git a/examples/weft_shop/src/client/AccountMenu.res.js b/examples/weft_shop/src/client/AccountMenu.res.js new file mode 100644 index 0000000..4179a43 --- /dev/null +++ b/examples/weft_shop/src/client/AccountMenu.res.js @@ -0,0 +1,57 @@ +'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 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 Store$WeftShop from "./Store.res.js"; +import * as Entrypoint$Weft from "@e-cline/weft/src/shared/Entrypoint.res.js"; +import * as ShopContext$WeftShop from "./ShopContext.res.js"; +import * as Navigation$WeftServer from "@e-cline/weft_server/src/shared/Navigation.res.js"; + +function AccountMenu(props) { + let match = ShopContext$WeftShop.use(); + let user = match.user; + let navigation = Navigation$WeftServer.useNavigation(); + let signOut = Rpc$WeftServer.use(Api$WeftShop.signOut, undefined); + return Jsx$WeftDom.Elements.jsx("div", { + id: "account-menu", + children: user !== undefined ? Jsx$WeftDom.jsxs(Jsx$WeftDom.jsxFragment, { + children: [ + Jsx$WeftDom.jsx(Link$WeftShop.make, { + href: "/account", + id: "account-link", + children: `Hi, ` + user + }), + Jsx$WeftDom.Elements.jsx("button", { + id: "sign-out", + onClick: param => { + (async () => { + let error = await signOut(); + if (error.TAG !== "Ok") { + return Store$WeftShop.toast(Api$WeftShop.describe(error._0)); + } + Store$WeftShop.toast("Signed out"); + return await Navigation$WeftServer.refresh(navigation); + })(); + }, + children: "Sign out" + }) + ] + }) : Jsx$WeftDom.Elements.jsx("button", { + id: "sign-in", + onClick: param => Store$WeftShop.openSignIn(), + children: "Sign in" + }) + }); +} + +Entrypoint$Weft.mark(AccountMenu, "@e-cline/weft_shop_example/src/client/AccountMenu.res#"); + +let make = AccountMenu; + +export { + make, +} +/* Not a pure module */ diff --git a/examples/weft_shop/src/client/AddToCart.res b/examples/weft_shop/src/client/AddToCart.res new file mode 100644 index 0000000..5f41c18 --- /dev/null +++ b/examples/weft_shop/src/client/AddToCart.res @@ -0,0 +1,63 @@ +/*** +Adds the picked variant to the cart (the `cart.add` RPC): the cart it answers with goes to the store, +so the header's count changes at once, with a toast. The quantity is this button's own state, kept +when the offer around it is fetched again for another variant (and clamped to what's in stock). +*/ + +@weft.component({kind: Entrypoint}) +let make = (~sku: string, ~name: string, ~stock: int) => { + let (quantity, setQuantity) = Weft.Hooks.useState(() => 1) + let (busy, setBusy) = Weft.Hooks.useState(() => false) + let (error, setError) = Weft.Hooks.useState(() => None) + let add = WeftServer.Rpc.use(Api.addToCart) + let most = Math.Int.min(stock, 10) + let quantity = Math.Int.max(1, Math.Int.min(quantity, most)) + let quantityId = Weft.Hooks.useId() +
+ + + + {switch error { + | Some(message) => + | None => WeftDom.Jsx.null + }} +
+} diff --git a/examples/weft_shop/src/client/AddToCart.res.js b/examples/weft_shop/src/client/AddToCart.res.js new file mode 100644 index 0000000..cf3b36c --- /dev/null +++ b/examples/weft_shop/src/client/AddToCart.res.js @@ -0,0 +1,93 @@ +'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 Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.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 Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js"; +import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js"; +import * as Rpc$WeftServer from "@e-cline/weft_server/src/shared/Rpc.res.js"; +import * as Store$WeftShop from "./Store.res.js"; +import * as Entrypoint$Weft from "@e-cline/weft/src/shared/Entrypoint.res.js"; + +function AddToCart(props) { + let stock = props.stock; + let name = props.name; + let sku = props.sku; + let match = Hooks$Weft.useState(() => 1); + let setQuantity = match[1]; + let match$1 = Hooks$Weft.useState(() => false); + let setBusy = match$1[1]; + let busy = match$1[0]; + let match$2 = Hooks$Weft.useState(() => {}); + let setError = match$2[1]; + let error = match$2[0]; + let add = Rpc$WeftServer.use(Api$WeftShop.addToCart, undefined); + let most = Math.min(stock, 10); + let quantity = Math.max(1, Math.min(match[0], most)); + let quantityId = Hooks$Weft.useId(); + return Jsx$WeftDom.Elements.jsxs("div", { + id: "add-to-cart", + children: [ + Jsx$WeftDom.Elements.jsx("label", { + htmlFor: quantityId, + children: "Quantity" + }), + Jsx$WeftDom.Elements.jsx("select", { + id: quantityId, + className: "quantity", + value: quantity.toString(), + disabled: stock === 0, + onChange: event => { + let value = Dom$WeftDom.Event.targetValue(event); + setQuantity(param => Stdlib_Option.getOr(Stdlib_Int.fromString(value, undefined), 1)); + }, + children: Stdlib_Array.fromInitializer(Math.max(1, most), index => index + 1 | 0).map(count => Jsx$WeftDom.Elements.jsxKeyed("option", { + value: count.toString(), + children: count.toString() + }, count.toString(), undefined)) + }), + Jsx$WeftDom.Elements.jsx("button", { + id: "add", + disabled: stock === 0 || busy, + onClick: param => { + setBusy(param => true); + (async () => { + let cart = await add({ + sku: sku, + quantity: quantity + }); + if (cart.TAG === "Ok") { + setError(param => {}); + Store$WeftShop.setCart(cart._0); + Store$WeftShop.toast(`Added ` + quantity.toString() + ` × ` + name + ` to your cart`); + } else { + let error = cart._0; + setError(param => Api$WeftShop.describe(error)); + } + return setBusy(param => false); + })(); + }, + children: stock === 0 ? "Sold out" : ( + busy ? "Adding…" : "Add to cart" + ) + }), + error !== undefined ? Jsx$WeftDom.Elements.jsx("p", { + id: "add-error", + role: "alert", + children: error + }) : Jsx$WeftDom.$$null + ] + }); +} + +Entrypoint$Weft.mark(AddToCart, "@e-cline/weft_shop_example/src/client/AddToCart.res#"); + +let make = AddToCart; + +export { + make, +} +/* Not a pure module */ diff --git a/examples/weft_shop/src/client/BlockFrame.res b/examples/weft_shop/src/client/BlockFrame.res new file mode 100644 index 0000000..c21ef0e --- /dev/null +++ b/examples/weft_shop/src/client/BlockFrame.res @@ -0,0 +1,66 @@ +/*** +Around every block of a server-driven page: an error boundary (a block whose service failed shows +why, with a Retry button, and the rest of the page is unaffected), and the block refetched by itself +(`useRefetchable`, from the block's own route): on Retry, and every `refreshEvery` seconds for a +block the server says is live. + +Retry refetches the block, then resets the boundary once the refetched block has rendered (not when +`refetch` resolves, which is when its render starts: a reset then would render the failed block +again), so what renders is the newly fetched block: another failure is caught again. +*/ + +@weft.component({kind: Entrypoint}) +let make = (~id: string, ~src: string, ~refreshEvery: option=?, ~children: Weft.Element.t) => { + let block = WeftServer.Navigation.useRefetchable(~src, children) + // Retry's reset, waiting for the refetch to have rendered. + let (retrying, setRetrying) = Weft.Hooks.useState(() => false) + let reset = Weft.Hooks.useRef(() => ()) + Weft.Hooks.useEffect2(() => { + if retrying && !block.isPending { + setRetrying(_ => false) + reset.current() + } + None + }, (retrying, block.isPending)) + // The interval calls the latest `refetch`. + let refetch = Weft.Hooks.useRef(block.refetch) + refetch.current = block.refetch + Weft.Hooks.useEffect2(() => + switch refreshEvery { + | Some(seconds) => + let timer = setInterval( + () => refetch.current()->Promise.catch(_ => Promise.resolve())->ignore, + seconds * 1000, + ) + Some(() => clearInterval(timer)) + | None => None + } + , (refreshEvery, src)) +
+ { + reset.current = resetBoundary + + }} + > + {block.element} + +
+} diff --git a/examples/weft_shop/src/client/BlockFrame.res.js b/examples/weft_shop/src/client/BlockFrame.res.js new file mode 100644 index 0000000..cb8030f --- /dev/null +++ b/examples/weft_shop/src/client/BlockFrame.res.js @@ -0,0 +1,85 @@ +'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 Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js"; +import * as Entrypoint$Weft from "@e-cline/weft/src/shared/Entrypoint.res.js"; +import * as ErrorBoundary$Weft from "@e-cline/weft/src/shared/ErrorBoundary.res.js"; +import * as Navigation$WeftServer from "@e-cline/weft_server/src/shared/Navigation.res.js"; + +function BlockFrame(props) { + let refreshEvery = props.refreshEvery; + let src = props.src; + let id = props.id; + let block = Navigation$WeftServer.useRefetchable(src, props.children); + let match = Hooks$Weft.useState(() => false); + let setRetrying = match[1]; + let retrying = match[0]; + let reset = Hooks$Weft.useRef(() => {}); + Hooks$Weft.useEffect2(() => { + if (retrying && !block.isPending) { + setRetrying(param => false); + reset.current(); + } + }, [ + retrying, + block.isPending + ]); + let refetch = Hooks$Weft.useRef(block.refetch); + refetch.current = block.refetch; + Hooks$Weft.useEffect2(() => { + if (refreshEvery === undefined) { + return; + } + let timer = setInterval(() => { + Stdlib_Promise.$$catch(refetch.current(undefined), param => Promise.resolve()); + }, refreshEvery * 1000 | 0); + return () => { + clearInterval(timer); + }; + }, [ + refreshEvery, + src + ]); + return Jsx$WeftDom.Elements.jsx("section", { + id: `block-` + id, + className: "block", + title: block.isPending ? "pending" : "idle", + "aria-busy": block.isPending ? "true" : "false", + children: Jsx$WeftDom.jsx(ErrorBoundary$Weft.make, { + fallback: (error, resetBoundary) => { + reset.current = resetBoundary; + return Jsx$WeftDom.Elements.jsxs("div", { + id: `block-error-` + id, + className: "block-error", + role: "alert", + children: [ + Jsx$WeftDom.Elements.jsx("p", { + children: "Couldn't load this: " + ErrorBoundary$Weft.message(error) + }), + Jsx$WeftDom.Elements.jsx("button", { + id: `retry-` + id, + disabled: retrying, + onClick: param => { + setRetrying(param => true); + Stdlib_Promise.$$catch(refetch.current(undefined), param => Promise.resolve()); + }, + children: retrying ? "Retrying…" : "Retry" + }) + ] + }); + }, + children: block.element + }) + }); +} + +Entrypoint$Weft.mark(BlockFrame, "@e-cline/weft_shop_example/src/client/BlockFrame.res#"); + +let make = BlockFrame; + +export { + make, +} +/* Not a pure module */ diff --git a/examples/weft_shop/src/client/Carousel.res b/examples/weft_shop/src/client/Carousel.res new file mode 100644 index 0000000..e873232 --- /dev/null +++ b/examples/weft_shop/src/client/Carousel.res @@ -0,0 +1,52 @@ +/*** A rail of cards (rendered on the server, as `children`), a page of four at a time. */ + +let pageSize = 4 + +@weft.component({kind: Entrypoint}) +let make = (~id: string, ~title: string, ~count: int, ~children: Weft.Element.t) => { + let (page, setPage) = Weft.Hooks.useState(() => 0) + let pages = Math.Int.max(1, (count + pageSize - 1) / pageSize) + // The rail can shrink under the page it was on (a refetch with fewer cards). + let page = Math.Int.min(page, pages - 1) + let first = page * pageSize +
+
+

{WeftDom.Jsx.string(title)}

+ {pages > 1 + ?
+ + + {WeftDom.Jsx.string( + `${Int.toString(first + 1)}–${Int.toString( + Math.Int.min(count, first + pageSize), + )} of ${Int.toString(count)}`, + )} + + +
+ : WeftDom.Jsx.null} +
+
+
+ children +
+
+
+} diff --git a/examples/weft_shop/src/client/Carousel.res.js b/examples/weft_shop/src/client/Carousel.res.js new file mode 100644 index 0000000..284b8c3 --- /dev/null +++ b/examples/weft_shop/src/client/Carousel.res.js @@ -0,0 +1,77 @@ +'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 Entrypoint$Weft from "@e-cline/weft/src/shared/Entrypoint.res.js"; + +function Carousel(props) { + let count = props.count; + let id = props.id; + let match = Hooks$Weft.useState(() => 0); + let setPage = match[1]; + let pages = Math.max(1, ((count + 4 | 0) - 1 | 0) / 4 | 0); + let page = Math.min(match[0], pages - 1 | 0); + let first = (page << 2); + return Jsx$WeftDom.Elements.jsxs("div", { + id: `carousel-` + id, + className: "carousel", + children: [ + Jsx$WeftDom.Elements.jsxs("div", { + className: "carousel-head", + children: [ + Jsx$WeftDom.Elements.jsx("h2", { + children: props.title + }), + pages > 1 ? Jsx$WeftDom.Elements.jsxs("div", { + className: "carousel-controls", + children: [ + Jsx$WeftDom.Elements.jsx("button", { + id: `prev-` + id, + "aria-label": "Previous", + disabled: page === 0, + onClick: param => setPage(param => page - 1 | 0), + children: "‹" + }), + Jsx$WeftDom.Elements.jsx("span", { + id: `position-` + id, + className: "carousel-position", + children: (first + 1 | 0).toString() + `–` + Math.min(count, first + 4 | 0).toString() + ` of ` + count.toString() + }), + Jsx$WeftDom.Elements.jsx("button", { + id: `next-` + id, + "aria-label": "Next", + disabled: page === (pages - 1 | 0), + onClick: param => setPage(param => page + 1 | 0), + children: "›" + }) + ] + }) : Jsx$WeftDom.$$null + ] + }), + Jsx$WeftDom.Elements.jsx("div", { + className: "carousel-window", + children: Jsx$WeftDom.Elements.jsx("div", { + className: "carousel-track", + style: Object.fromEntries([[ + "transform", + `translateX(-` + (page * 100 | 0).toString() + `%)` + ]]), + children: props.children + }) + }) + ] + }); +} + +Entrypoint$Weft.mark(Carousel, "@e-cline/weft_shop_example/src/client/Carousel.res#"); + +let pageSize = 4; + +let make = Carousel; + +export { + pageSize, + make, +} +/* Not a pure module */ diff --git a/examples/weft_shop/src/client/CartBadge.res b/examples/weft_shop/src/client/CartBadge.res new file mode 100644 index 0000000..08da2d5 --- /dev/null +++ b/examples/weft_shop/src/client/CartBadge.res @@ -0,0 +1,58 @@ +/*** +The cart in the header: how many items (from `ShopContext`, so an RPC's answer shows at once), and a +drawer listing them, rendered into `` (a portal) while it's open. +*/ + +open Types + +module Drawer = { + @weft.component + let make = (~cart: cart, ~onClose: unit => unit) => { + let {currency} = ShopContext.use() + WeftDom.Portal.create( + , + ~container=WeftDom.Dom.document->WeftDom.Dom.body, + ) + } +} + +@weft.component({kind: Entrypoint}) +let make = () => { + let {cart} = ShopContext.use() + let (isOpen, setOpen) = Weft.Hooks.useState(() => false) +
+ + {isOpen ? setOpen(_ => false)} /> : WeftDom.Jsx.null} +
+} diff --git a/examples/weft_shop/src/client/CartBadge.res.js b/examples/weft_shop/src/client/CartBadge.res.js new file mode 100644 index 0000000..47e789a --- /dev/null +++ b/examples/weft_shop/src/client/CartBadge.res.js @@ -0,0 +1,92 @@ +'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 Link$WeftShop from "./Link.res.js"; +import * as Money$WeftShop from "../shared/Money.res.js"; +import * as Portal$WeftDom from "@e-cline/weft_dom/src/shared/Portal.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 ShopContext$WeftShop from "./ShopContext.res.js"; + +function CartBadge$Drawer(props) { + let onClose = props.onClose; + let cart = props.cart; + let match = ShopContext$WeftShop.use(); + let currency = match.currency; + return Portal$WeftDom.create(Jsx$WeftDom.Elements.jsxs("aside", { + id: "cart-drawer", + role: "dialog", + "aria-label": "Cart", + children: [ + Jsx$WeftDom.Elements.jsx("h2", { + children: "Your cart" + }), + Primitive_object.equal(cart.lines, []) ? Jsx$WeftDom.Elements.jsx("p", { + id: "drawer-empty", + children: "Nothing here yet." + }) : Jsx$WeftDom.Elements.jsx("ul", { + id: "drawer-lines", + children: cart.lines.map(line => Jsx$WeftDom.Elements.jsxKeyed("li", { + id: `drawer-` + line.sku, + children: line.name + ` (` + line.variant + `) × ` + line.quantity.toString() + `: ` + Money$WeftShop.format(line.unitPrice * line.quantity | 0, currency) + }, line.sku, undefined)) + }), + Jsx$WeftDom.Elements.jsx("p", { + id: "drawer-subtotal", + children: `Subtotal: ` + Money$WeftShop.format(cart.subtotal, currency) + }), + Jsx$WeftDom.Elements.jsx("div", { + onClick: param => onClose(), + children: Jsx$WeftDom.jsx(Link$WeftShop.make, { + href: "/cart", + id: "drawer-cart-link", + children: "View cart" + }) + }), + Jsx$WeftDom.Elements.jsx("button", { + id: "drawer-close", + onClick: param => onClose(), + children: "Close" + }) + ] + }), document.body, undefined); +} + +let Drawer = { + make: CartBadge$Drawer +}; + +function CartBadge(props) { + let match = ShopContext$WeftShop.use(); + let cart = match.cart; + let match$1 = Hooks$Weft.useState(() => false); + let setOpen = match$1[1]; + let isOpen = match$1[0]; + return Jsx$WeftDom.Elements.jsxs("div", { + id: "cart-badge", + children: [ + Jsx$WeftDom.Elements.jsx("button", { + id: "cart-button", + "aria-expanded": isOpen ? "true" : "false", + onClick: param => setOpen(open_ => !open_), + children: `Cart (` + cart.count.toString() + `)` + }), + isOpen ? Jsx$WeftDom.jsx(CartBadge$Drawer, { + cart: cart, + onClose: () => setOpen(param => false) + }) : Jsx$WeftDom.$$null + ] + }); +} + +Entrypoint$Weft.mark(CartBadge, "@e-cline/weft_shop_example/src/client/CartBadge.res#"); + +let make = CartBadge; + +export { + Drawer, + make, +} +/* Not a pure module */ diff --git a/examples/weft_shop/src/client/CartLines.res b/examples/weft_shop/src/client/CartLines.res new file mode 100644 index 0000000..aebba17 --- /dev/null +++ b/examples/weft_shop/src/client/CartLines.res @@ -0,0 +1,91 @@ +/*** +The cart's lines, with steppers. A change shows at once (the quantity it's going to be, kept in a +reducer while the `cart.setQuantity` RPC is out), then the cart the server answers with replaces the +lot (through the store, so the header's count follows). Lines come from `ShopContext`, the newest of +the server's cart and the client's. +*/ + +open Types + +type pending = Map.t + +type action = + | Change(string, int) + | Settled(string) + +let reducer = (state: pending, action) => { + let next = Map.fromArray(state->Map.entries->Iterator.toArray) + switch action { + | Change(sku, quantity) => next->Map.set(sku, quantity) + | Settled(sku) => next->Map.delete(sku)->ignore + } + next +} + +@weft.component({kind: Entrypoint}) +let make = () => { + let {cart, currency} = ShopContext.use() + let (pending, dispatch) = Weft.Hooks.useReducer(reducer, Map.make()) + let setQuantity = WeftServer.Rpc.use(Api.setQuantity) + let change = (sku, quantity) => { + dispatch(Change(sku, quantity)) + + ( + async () => { + switch await setQuantity({sku, quantity}) { + | Ok(cart) => Store.setCart(cart) + | Error(error) => Store.toast(Api.describe(error)) + } + dispatch(Settled(sku)) + } + )()->ignore + } + let lines = + cart.lines + ->Array.map(line => { + ...line, + quantity: pending->Map.get(line.sku)->Option.getOr(line.quantity), + }) + ->Array.filter(line => line.quantity > 0) + lines == [] + ?
+

{WeftDom.Jsx.string("Your cart is empty.")}

+ {WeftDom.Jsx.string("Keep shopping")} +
+ :
    + {lines + ->Array.map(line => +
  • + {WeftDom.Jsx.string(line.name)} + {WeftDom.Jsx.string(line.variant)} + + + + {WeftDom.Jsx.string(Int.toString(line.quantity))} + + + + + {WeftDom.Jsx.string(Money.format(line.unitPrice * line.quantity, currency))} + + +
  • + ) + ->WeftDom.Jsx.array} +
+} diff --git a/examples/weft_shop/src/client/CartLines.res.js b/examples/weft_shop/src/client/CartLines.res.js new file mode 100644 index 0000000..c37b992 --- /dev/null +++ b/examples/weft_shop/src/client/CartLines.res.js @@ -0,0 +1,138 @@ +'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 Api$WeftShop from "../shared/Api.res.js"; +import * as Link$WeftShop from "./Link.res.js"; +import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js"; +import * as Money$WeftShop from "../shared/Money.res.js"; +import * as Rpc$WeftServer from "@e-cline/weft_server/src/shared/Rpc.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 Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js"; +import * as ShopContext$WeftShop from "./ShopContext.res.js"; + +function reducer(state, action) { + let next = new Map(state.entries().toArray()); + if (action.TAG === "Change") { + next.set(action._0, action._1); + } else { + next.delete(action._0); + } + return next; +} + +function CartLines(props) { + let match = ShopContext$WeftShop.use(); + let currency = match.currency; + let match$1 = Hooks$Weft.useReducer(reducer, new Map()); + let dispatch = match$1[1]; + let pending = match$1[0]; + let setQuantity = Rpc$WeftServer.use(Api$WeftShop.setQuantity, undefined); + let change = (sku, quantity) => { + dispatch({ + TAG: "Change", + _0: sku, + _1: quantity + }); + (async () => { + let cart = await setQuantity({ + sku: sku, + quantity: quantity + }); + if (cart.TAG === "Ok") { + Store$WeftShop.setCart(cart._0); + } else { + Store$WeftShop.toast(Api$WeftShop.describe(cart._0)); + } + return dispatch({ + TAG: "Settled", + _0: sku + }); + })(); + }; + let lines = match.cart.lines.map(line => ({ + sku: line.sku, + slug: line.slug, + name: line.name, + variant: line.variant, + unitPrice: line.unitPrice, + quantity: Stdlib_Option.getOr(pending.get(line.sku), line.quantity) + })).filter(line => line.quantity > 0); + if (Primitive_object.equal(lines, [])) { + return Jsx$WeftDom.Elements.jsxs("div", { + id: "cart-empty", + children: [ + Jsx$WeftDom.Elements.jsx("p", { + children: "Your cart is empty." + }), + Jsx$WeftDom.jsx(Link$WeftShop.make, { + href: "/", + id: "keep-shopping", + children: "Keep shopping" + }) + ] + }); + } else { + return Jsx$WeftDom.Elements.jsx("ul", { + id: "cart-lines", + children: lines.map(line => Jsx$WeftDom.Elements.jsxsKeyed("li", { + id: `line-` + line.sku, + className: "cart-line", + children: [ + Jsx$WeftDom.jsx(Link$WeftShop.make, { + href: `/p/` + line.slug, + children: line.name + }), + Jsx$WeftDom.Elements.jsx("span", { + className: "line-variant", + children: line.variant + }), + Jsx$WeftDom.Elements.jsxs("span", { + className: "stepper", + children: [ + Jsx$WeftDom.Elements.jsx("button", { + id: `less-` + line.sku, + "aria-label": "One fewer", + onClick: param => change(line.sku, line.quantity - 1 | 0), + children: "−" + }), + Jsx$WeftDom.Elements.jsx("span", { + id: `quantity-` + line.sku, + className: "line-quantity", + children: line.quantity.toString() + }), + Jsx$WeftDom.Elements.jsx("button", { + id: `more-` + line.sku, + "aria-label": "One more", + disabled: line.quantity >= 10, + onClick: param => change(line.sku, line.quantity + 1 | 0), + children: "+" + }) + ] + }), + Jsx$WeftDom.Elements.jsx("span", { + className: "line-total", + children: Money$WeftShop.format(line.unitPrice * line.quantity | 0, currency) + }), + Jsx$WeftDom.Elements.jsx("button", { + id: `remove-` + line.sku, + onClick: param => change(line.sku, 0), + children: "Remove" + }) + ] + }, line.sku, undefined)) + }); + } +} + +Entrypoint$Weft.mark(CartLines, "@e-cline/weft_shop_example/src/client/CartLines.res#"); + +let make = CartLines; + +export { + reducer, + make, +} +/* Not a pure module */ diff --git a/examples/weft_shop/src/client/CartSummaryFrame.res b/examples/weft_shop/src/client/CartSummaryFrame.res new file mode 100644 index 0000000..7d93a8d --- /dev/null +++ b/examples/weft_shop/src/client/CartSummaryFrame.res @@ -0,0 +1,22 @@ +/*** +The cart's totals, rendered on the server (`children`: the discount, shipping, and what's left for +free shipping are the server's to work out), fetched again by themselves (`useRefetchable`) whenever +the cart changes: the version in `ShopContext` going up, from any entrypoint's RPC. +*/ + +@weft.component({kind: Entrypoint}) +let make = (~children: Weft.Element.t) => { + let {cart} = ShopContext.use() + let summary = WeftServer.Navigation.useRefetchable(~src="/cart/summary", children) + let refetch = Weft.Hooks.useRef(summary.refetch) + refetch.current = summary.refetch + let shown = Weft.Hooks.useRef(cart.version) + Weft.Hooks.useEffect1(() => { + if cart.version != shown.current { + shown.current = cart.version + refetch.current()->Promise.catch(_ => Promise.resolve())->ignore + } + None + }, [cart.version]) +
{summary.element}
+} diff --git a/examples/weft_shop/src/client/CartSummaryFrame.res.js b/examples/weft_shop/src/client/CartSummaryFrame.res.js new file mode 100644 index 0000000..0b07a53 --- /dev/null +++ b/examples/weft_shop/src/client/CartSummaryFrame.res.js @@ -0,0 +1,38 @@ +'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 Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js"; +import * as Entrypoint$Weft from "@e-cline/weft/src/shared/Entrypoint.res.js"; +import * as ShopContext$WeftShop from "./ShopContext.res.js"; +import * as Navigation$WeftServer from "@e-cline/weft_server/src/shared/Navigation.res.js"; + +function CartSummaryFrame(props) { + let match = ShopContext$WeftShop.use(); + let cart = match.cart; + let summary = Navigation$WeftServer.useRefetchable("/cart/summary", props.children); + let refetch = Hooks$Weft.useRef(summary.refetch); + refetch.current = summary.refetch; + let shown = Hooks$Weft.useRef(cart.version); + Hooks$Weft.useEffect1(() => { + if (cart.version !== shown.current) { + shown.current = cart.version; + Stdlib_Promise.$$catch(refetch.current(undefined), param => Promise.resolve()); + } + }, [cart.version]); + return Jsx$WeftDom.Elements.jsx("div", { + id: "summary-frame", + "aria-busy": summary.isPending ? "true" : "false", + children: summary.element + }); +} + +Entrypoint$Weft.mark(CartSummaryFrame, "@e-cline/weft_shop_example/src/client/CartSummaryFrame.res#"); + +let make = CartSummaryFrame; + +export { + make, +} +/* Not a pure module */ diff --git a/examples/weft_shop/src/client/CheckoutForm.res b/examples/weft_shop/src/client/CheckoutForm.res new file mode 100644 index 0000000..c314ad5 --- /dev/null +++ b/examples/weft_shop/src/client/CheckoutForm.res @@ -0,0 +1,223 @@ +/*** +Checking out, in three steps (a reducer): the address (checked as the `checkout.placeOrder` RPC will +check it, before moving on), delivery, then a review and placing the order. Its fields' labels are +linked by IDs from `useId`. If someone else bought the last of something meanwhile, the server says +which, and nothing is charged; otherwise it's on to the order's page. +*/ + +open Types + +type step = Address | Delivery | Review + +type state = { + step: step, + details: checkout, + error: option, + placing: bool, +} + +type field = Name | Street | City | Postcode | GiftNote + +type action = + | Edit(field, string) + | Choose(delivery) + | Next + | Back + | Placing + | Failed(string) + +let reducer = (state, action) => + switch action { + | Edit(field, value) => + let details = state.details + { + ...state, + details: switch field { + | Name => {...details, name: value} + | Street => {...details, street: value} + | City => {...details, city: value} + | Postcode => {...details, postcode: value} + | GiftNote => {...details, giftNote: Some(value)} + }, + } + | Choose(delivery) => {...state, details: {...state.details, delivery}} + | Next => + switch state.step { + | Address => + switch Api.checkAddress(Api.trimmed(state.details)) { + | Ok() => {...state, step: Delivery, error: None} + | Error(error) => {...state, error: Some(Api.message(error))} + } + | Delivery => {...state, step: Review, error: None} + | Review => state + } + | Back => + switch state.step { + | Address | Delivery => {...state, step: Address, error: None} + | Review => {...state, step: Delivery, error: None} + } + | Placing => {...state, placing: true, error: None} + | Failed(error) => {...state, placing: false, error: Some(error)} + } + +module Field = { + @weft.component + let make = (~label: string, ~name: string, ~value: string, ~autoComplete: string, ~onEdit) => { + let id = Weft.Hooks.useId() +
+ + onEdit(WeftDom.Dom.Event.targetValue(event))} + /> +
+ } +} + +@weft.component({kind: Entrypoint}) +let make = (~total: cents, ~count: int) => { + let {currency, user} = ShopContext.use() + let (state, dispatch) = Weft.Hooks.useReducer( + reducer, + { + step: Address, + details: { + name: user->Option.getOr(""), + street: "", + city: "", + postcode: "", + delivery: Standard, + giftNote: None, + }, + error: None, + placing: false, + }, + ) + let navigation = WeftServer.Navigation.useNavigation() + let placeOrder = WeftServer.Rpc.use(Api.placeOrder) + let {details} = state + let edit = (field, value) => dispatch(Edit(field, value)) + let place = () => { + dispatch(Placing) + + ( + async () => + switch await placeOrder(details) { + | Ok(orderId) => await navigation->WeftServer.Navigation.navigate(`/orders/${orderId}`) + | Error(error) => dispatch(Failed(Api.describe(error))) + } + )()->ignore + } +
+
    + {[(Address, "Address"), (Delivery, "Delivery"), (Review, "Review")] + ->Array.map(((step, label)) => +
  1. + {WeftDom.Jsx.string(label)} +
  2. + ) + ->WeftDom.Jsx.array} +
+
{ + WeftDom.Dom.Event.preventDefault(event) + state.step == Review ? place() : dispatch(Next) + }} + > + {switch state.step { + | Address => +
+ {WeftDom.Jsx.string("Where to?")} + + + + +
+ | Delivery => +
+ {WeftDom.Jsx.string("How fast?")} + {[(Standard, "Standard (3–5 days)"), (Express, "Express (next day)")] + ->Array.map(((delivery, label)) => { + let value = delivery == Standard ? "standard" : "express" + + }) + ->WeftDom.Jsx.array} + +