diff --git a/CHANGELOG.md b/CHANGELOG.md index b9673a4e..3312e893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to BareDOM will be documented in this file. +## [4.0.0-alpha.1] - 2026-06-04 + +**Pre-release — published on the npm `alpha` dist-tag (`npm install @vanelsas/baredom@alpha`). The stable `latest` line is unaffected, and `^3.x` ranges never resolve this version.** Patch over `4.0.0-alpha.0` carrying one core fix surfaced while dogfooding the write-side demo. The write-side element surface (``, ``, the `` invalidation substrate) is unchanged from `4.0.0-alpha.0`. + +### Fixed + +- **`x-select`** — `.value` now reflects the **current user selection** instead of the (never-updated) value *attribute*. Consumers reading `el.value` — `x-form`'s submission collection and any code reading the control directly (e.g. a status-filter dropdown) — got a stale value after the user picked an option. The getter now reads the live inner ` + + + + + + + + + +
+ + + + diff --git a/demo/barebuild-invalidate-on.html b/demo/barebuild-invalidate-on.html new file mode 100644 index 00000000..d5eebf74 --- /dev/null +++ b/demo/barebuild-invalidate-on.html @@ -0,0 +1,83 @@ + + + + + barebuild-invalidate-on demo + + + + + + + + +

<barebuild-invalidate-on>

+

+ Placed as a child of a source element, it listens to its parentNode + for the configured event; on a when-phase / + when-name match it dispatches a document-level + barebuild-invalidate {src}. This demo uses a plain source element + that emits a mock barebuild-action-state; the log shows when the + invalidate fires. Watch the difference between a matching and a non-matching phase, + and the manual .invalidate() trigger. +

+ + +
+ source element (parent) + +
+ +
+ + + +
+ +
+ + + + diff --git a/docs/barebuild-action.md b/docs/barebuild-action.md index fcec6a34..f4a3a426 100644 --- a/docs/barebuild-action.md +++ b/docs/barebuild-action.md @@ -10,6 +10,35 @@ Non-visual element that wraps a *submit emitter* (any **descendant element** dis --- +## Rendering & placement (alpha-port corrections) + +"Non-visual" is true of the *element itself* but it **wraps visible content**, so unlike the +leaf brokers (`barebuild-data`, `barebuild-invalidate-on`) it must **render its slotted +subtree**: + +- Its shadow root carries a **``** (`ensure-shadow! slot? = true`). Without one the + wrapped form stays in the light DOM and *functions* (events, `.value`, submit all work) but + **never renders** — a 0×0 trap the alpha port hit. A content-wrapper cannot be cloned from a + childless broker. +- `:host` is **`display:block`**, not `display:none` (which hides the content) and not + `display:contents` (which collapses the content when the action is a flex-item, e.g. inside a + modal's flex dialog). +- **Placement:** when the emitter is slotted into a layout host that styles its slotted + children (e.g. ``), wrap the **host**, not the inner form — wrapping the form makes + the *action* the host's slotted child and breaks its `::slotted` layout. The composed submit + event still bubbles out of the host to the action. (`…`) + +## Coordination is a protocol, not this element's job + +This element publishes `barebuild-action-state`; **invalidation and navigation are separate +document-level protocols** — `barebuild-invalidate {src}` (a matching `` +refetches) and `barebuild-navigate {path}`. `` is one declarative +emitter of the first, but **any code can dispatch them** — so a hand-wired write the action +can't drive (a delete via a confirm dialogue, a delegated row button) coordinates *identically* +to a declarative one. Treat the two protocols as the public contract; the elements are sugar. + +--- + ## Tag ```html @@ -56,7 +85,7 @@ Registration is idempotent. | `action` | string | **required** | URL to which the mutation is sent. **The identity of this mutation.** | | `method` | string | `POST` | HTTP method. Typical: `POST`, `PUT`, `PATCH`, `DELETE`. | | `submit-event` | string | **required** | Name of the cancelable event to listen for on the slotted subtree. Any **descendant** dispatching a cancelable bubbling event of this name with a values map at `values-path` in `detail` is a valid driver. **No default; no implicit coupling to any specific emitter.** The action contains no string literal `"x-form-submit"` anywhere in its source. Wiring to `` looks like `submit-event="x-form-submit"`; wiring to anything else looks the same with a different value. | -| `values-path` | string (EDN) | `[:values]` | EDN-literal vector indicating where in `event.detail` to find the values map. Parsed via `cljs.reader/read-string`, then `get-in`. Default `[:values]` so `` works unconfigured. Examples: `values-path="[:payload]"`, `values-path="[:detail :form-data]"`. JSON arrays accepted (`values-path='["payload"]'`). | +| `values-path` | string (EDN-shaped) | `[:values]` | EDN-vector-shaped string indicating where in `event.detail` to find the values map. Parsed by a small hand-rolled tokenizer (NOT `cljs.reader` — that would add ~40 KB to this per-component ESM module): the surrounding brackets are stripped, the inner tokens split on whitespace, and a leading `:` / surrounding quotes trimmed from each, then the JS object is walked by those string keys. Default `[:values]` so `` works unconfigured. Examples: `values-path="[:payload]"`, `values-path="[:detail :form-data]"`. JSON-array form accepted (`values-path='["payload"]'`). | **V1 is JSON-only.** The request body is always `application/json`, encoded from the values map at `values-path`. There is no `enctype` attribute. Form-urlencoded and `multipart/form-data` (file uploads) are V2. @@ -76,6 +105,7 @@ Registration is idempotent. | `.method` | string | reflects attribute | | | `.submitEvent` | string | reflects `submit-event` attribute | | | `.valuesPath` | string | reflects `values-path` attribute | | +| `.valuesTransform` | function | no (imperative-only; no attribute) | Optional `(values) → values` applied to the payload **before** JSON-encoding. The seam for payload hygiene the action cannot itself know — blank-stripping (so an unset control's `""` doesn't shadow a server default on a POST) or numeric coercion (a number control reports a string). Absent / non-function → the payload is encoded as-is (the default). Set it imperatively: `el.valuesTransform = withoutBlanks;`. | **No `.stateJs`.** None is needed — `.state` is already a plain JS object readable by any consumer (vanilla JS or a separately-compiled CLJS app), for the same cross-`cljs.core`-runtime reason `` is JS-shaped (plan Decision #6). @@ -119,7 +149,7 @@ The top-level `name` is what `` and `` dispatches `barebuild-inva ``` -`.submit!({})` bypasses event listening. The `submit-event` attribute is still required (the contract names what the action would listen for if not driven imperatively); pass `action-trigger` or any sentinel and never dispatch it. +`.submit({})` bypasses event listening. The `submit-event` attribute is still required (the contract names what the action would listen for if not driven imperatively); pass `action-trigger` or any sentinel and never dispatch it. ### Custom emitter with `values-path` diff --git a/docs/barebuild-invalidate-on.md b/docs/barebuild-invalidate-on.md index 3bd7633d..c8e8462d 100644 --- a/docs/barebuild-invalidate-on.md +++ b/docs/barebuild-invalidate-on.md @@ -49,7 +49,7 @@ Registration is idempotent. | `event` | string | `barebuild-action-state` | Event name to listen for on `parentNode`. For binding to a data broker's state: `event="barebuild-data-state"`. For binding to the router: `event="barebuild-route-change"`. | | `when-phase` | string | (unset) | Optional matcher. When set, compared with string equality against `event.detail.state.phase`. | | `when-name` | string | (unset) | Optional matcher. When set, compared with string equality against `event.detail.name`. | -| `src` | string | **required** | URL to invalidate. Dispatched as `event.detail.src` of the resulting `barebuild-invalidate` event. **Exact `URL.pathname` equality** is used by `` to decide if it should refetch (no wildcards in V1). | +| `src` | string | **required** | URL to invalidate. Dispatched as `event.detail.src` of the resulting `barebuild-invalidate` event. **Exact `origin + pathname + query` equality** (each side resolved against the page origin) is used by `` to decide if it should refetch — so `/api/items?page=1` and `?page=2` stay distinct, and a cross-origin `https://other/api/items` never matches the page's own `/api/items` (no wildcards in V1). | **At least one of `when-phase` / `when-name` is required.** Omitting both logs an error and the element no-ops on every event. Setting both ANDs — the invalidate dispatches only when *both* matchers fire on the same event. @@ -119,7 +119,7 @@ The event bubbles to `document` so any `` anywhere on the page c | Method | Signature | Description | |---|---|---| -| `invalidate!` | `()` → void | Manual trigger. Dispatches `barebuild-invalidate {src}` immediately, bypassing event listening. Useful for "user clicked refresh" buttons. | +| `invalidate` | `()` → void | Manual trigger. Dispatches `barebuild-invalidate {src}` immediately, bypassing event listening. Useful for "user clicked refresh" buttons. | --- @@ -141,13 +141,14 @@ Two lines, two named effects. No wildcards. (URL pattern matching is V2 if a rea ## URL Match Semantics (V1) -A `` refetches when it sees a `barebuild-invalidate` event whose `event.detail.src` equals its own `src` by **exact `URL.pathname` equality**: +A `` refetches when it sees a `barebuild-invalidate` event whose `event.detail.src` equals its own `src` by **exact `origin + pathname + query` equality** (each side resolved against the page origin): | Invalidate `src` | Matches data `src` | |---|---| | `/api/users` | `/api/users` | | `/api/users?q=foo` | `/api/users?q=foo` (query strings must match exactly if present on both sides) | | `/api/users` | `/api/users/42` → **NO** | +| `https://other.example/api/users` | `/api/users` → **NO** (different origin) | | `/api/users*` | `/api/users` → **NO** (no wildcards in V1) | If you need "invalidate this URL and everything under it," write multiple `` children. Pattern matching is V2. @@ -190,12 +191,12 @@ When the flaky action errors, refetch the health-check. ``` -`.invalidate!()` bypasses matchers and the listener entirely — it dispatches `barebuild-invalidate` unconditionally. The `when-name` is still required (every wire is named); use a sentinel that will never match, or place the element inside a source whose events it can safely ignore. The orphan-no-op warning fires once at connect if there's no `parentNode` event source; harmless. +`.invalidate()` bypasses matchers and the listener entirely — it dispatches `barebuild-invalidate` unconditionally. The `when-name` is still required (every wire is named); use a sentinel that will never match, or place the element inside a source whose events it can safely ignore. The orphan-no-op warning fires once at connect if there's no `parentNode` event source; harmless. ### Reacting to data success (rare but legal) diff --git a/docs/components.md b/docs/components.md index b4e3179d..fe904e12 100644 --- a/docs/components.md +++ b/docs/components.md @@ -2,7 +2,7 @@ The full catalogue of BareDOM components — 103 UI web components across 11 categories. Every UI component is a native Custom Element with auto-generated TypeScript declarations and is exposed in all five [framework adapters](../README.md#framework-adapters). -A twelfth category, **Orchestration**, holds the [BareBuild](../barebuild/docs/read-side.md) read-side elements (router / route / data). They are native custom elements too, but they are *orchestration, not UI*: CLJS-first, **not** adapter-wrapped, and shipped as opt-in ESM entries. +A twelfth category, **Orchestration**, holds the [BareBuild](../barebuild/docs/read-side.md) read-side elements (router / route / data) plus the **alpha** write-side pair (action / invalidate-on). They are native custom elements too, but they are *orchestration, not UI*: CLJS-first, **not** adapter-wrapped, and shipped as opt-in ESM entries. Tag names are case-insensitive in HTML but always lowercase-kebab-case in the source. Component documentation links to deeper guides with API tables, examples, and a11y notes. @@ -179,15 +179,17 @@ Tag names are case-insensitive in HTML but always lowercase-kebab-case in the so | [``](./x-i18n.md) | Inline translation element. Renders translated text for a key, resolved from the nearest ``. | | [``](./x-i18n-provider.md) | Internationalization provider. Fetches translation JSON and supplies locale context to descendant ``. | -## Orchestration (3) +## Orchestration (5) -[BareBuild](../barebuild/docs/read-side.md) read-side elements. Non-visual; compose by containment and events with the UI components above. CLJS-first, not adapter-wrapped. +[BareBuild](../barebuild/docs/read-side.md) read- and write-side elements. Non-visual; compose by containment and events with the UI components above. CLJS-first, not adapter-wrapped. The write-side pair (`barebuild-action` / `barebuild-invalidate-on`) is **ALPHA (4.0.0-alpha)** — shapes may change before stable. | Tag | Description | |-----|-------------| | [``](./barebuild-router.md) | URL-as-data. Watches `popstate`, intercepts opt-in `` clicks, and publishes the active route as a value. Owns no authoritative state. | | [``](./barebuild-route.md) | Passive child of ``. Owns its own visibility (display toggle); self-registers by bubbling event. Identity-preserving — never rebuilds its body. | | [``](./barebuild-data.md) | Fetches a URL and publishes the response as one value (`.state`), dispatching `barebuild-data-state` on every phase transition. GET/JSON, V1. | +| [``](./barebuild-action.md) | **(alpha)** Wraps a submit emitter by containment, POSTs the values as JSON to `action`, and publishes the HTTP result as `.state` + a `barebuild-action-state` event per phase. | +| [``](./barebuild-invalidate-on.md) | **(alpha)** Child of a source element; on a `when-phase`/`when-name` match it dispatches a document-level `barebuild-invalidate {src}` that matching data brokers self-match and refetch. | --- diff --git a/package.json b/package.json index 16548b09..f3e638e8 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,16 @@ "import": "./dist/barebuild-data.js", "default": "./dist/barebuild-data.js" }, + "./barebuild-action": { + "types": "./dist/barebuild-action.d.ts", + "import": "./dist/barebuild-action.js", + "default": "./dist/barebuild-action.js" + }, + "./barebuild-invalidate-on": { + "types": "./dist/barebuild-invalidate-on.d.ts", + "import": "./dist/barebuild-invalidate-on.js", + "default": "./dist/barebuild-invalidate-on.js" + }, "./x-avatar": { "types": "./dist/x-avatar.d.ts", "import": "./dist/x-avatar.js", @@ -588,7 +598,7 @@ "bugs": { "url": "https://github.com/avanelsas/baredom/issues" }, - "version": "3.3.0", + "version": "4.0.0-alpha.1", "sideEffects": [ "./dist/*.js" ], diff --git a/public/index.html b/public/index.html index f91a32c7..c70d8401 100644 --- a/public/index.html +++ b/public/index.html @@ -469,6 +469,8 @@

Library demo

{ name: "Router", tag: "barebuild-router", file: "barebuild-router", cat: "orchestration" }, { name: "Route", tag: "barebuild-route", file: "barebuild-route", cat: "orchestration" }, { name: "Data Broker", tag: "barebuild-data", file: "barebuild-data", cat: "orchestration" }, + { name: "Action", tag: "barebuild-action", file: "barebuild-action", cat: "orchestration" }, + { name: "Invalidate On", tag: "barebuild-invalidate-on", file: "barebuild-invalidate-on", cat: "orchestration" }, { name: "Alert", tag: "x-alert", file: "x-alert", cat: "feedback" }, { name: "Avatar", tag: "x-avatar", file: "x-avatar", cat: "data" }, { name: "Avatar Group", tag: "x-avatar-group", file: "x-avatar-group", cat: "data" }, diff --git a/shadow-cljs.edn b/shadow-cljs.edn index f95af101..b8f14cea 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -589,4 +589,16 @@ {init baredom.exports.barebuild-data/init} :depends-on #{:base}} + ;; Write-side coordination (ALPHA, 4.0.0-alpha — explicitly unstable). Shipped + ;; on the `alpha` npm dist-tag only; shapes may change before stable. + :barebuild-action + {:exports + {init baredom.exports.barebuild-action/init} + :depends-on #{:base}} + + :barebuild-invalidate-on + {:exports + {init baredom.exports.barebuild-invalidate-on/init} + :depends-on #{:base}} + }}}} diff --git a/src/baredom/components/barebuild/lifecycle.cljs b/src/baredom/components/barebuild/lifecycle.cljs new file mode 100644 index 00000000..1912aa6b --- /dev/null +++ b/src/baredom/components/barebuild/lifecycle.cljs @@ -0,0 +1,108 @@ +(ns baredom.components.barebuild.lifecycle + (:require + [baredom.utils.dom :as du])) + +;; Shared fetch + state-transition lifecycle for the BareBuild server-state +;; brokers — barebuild-data (read side) and barebuild-action (write side). Both +;; own NO authoritative state: they fetch a URL, cache the resulting plain-JS +;; state object, and dispatch ONE CustomEvent per phase transition. The two +;; differ only in what `cfg` supplies; everything else here — the +;; abort-controller stale-callback guard, the 204/empty-body → success(nil) +;; parse, AbortError suppression, and the shallow value-equality change guard — +;; is identical and lives here so the read and write sides cannot drift. +;; (Source-listener bind/detach mechanics shared by the write-side elements live +;; in the sibling barebuild/listeners ns — a separate concern, kept separate.) +;; +;; `cfg` keys: +;; :k-state instance-field key caching the state object +;; :k-abort instance-field key holding the in-flight AbortController +;; :payload-fn (fn [^js state]) → the by-REFERENCE payload field (.-data / .-response) +;; :event-name the *-state event dispatched on every transition +;; :detail-fn (fn [^js el ^js state]) → the event-detail JS object +;; :success-fn (fn [value status]) → the success/loaded state constructor +;; :error-fn (fn [message status]) → the error state constructor + +(defn same-state? + "Shallow value-equality for two JS state objects. JS `=`/`not=` are identity, + so two distinct-but-equal objects would re-fire the event; compare field by + field instead. The payload field (the fetched body — `.-data` on the read side, + `.-response` on the write side, supplied as the `payload-fn` accessor) is + compared by REFERENCE: a fresh fetch yields a new value worth an event. + `payload-fn` is a direct field accessor, NOT a string key — so the field name + stays extern-inferred and co-located with the constructor that stamps it, rather + than a loose string that could silently mis-address (both sides → undefined → + wrongly equal → dedup breaks quietly)." + [^js a ^js b payload-fn] + (and (some? a) (some? b) + (= (.-phase a) (.-phase b)) + (identical? (payload-fn a) (payload-fn b)) + (= (.-error a) (.-error b)) + (= (.-httpStatus a) (.-httpStatus b)))) + +(defn set-state! + "Cache `new-state` and dispatch the configured event ONLY when it differs + (shallow) from the current value — the epochal change guard: exactly one event + per transition, never a duplicate. Idle resets that re-establish a resting + value (not a fetch-lifecycle transition) flow through here too: an equal value + is a no-op, a changed one fires." + [^js el ^js new-state {:keys [k-state payload-fn event-name detail-fn]}] + (when-not (same-state? new-state (du/getv el k-state) payload-fn) + (du/setv! el k-state new-state) + (du/dispatch! el event-name (detail-fn el new-state)))) + +(defn abort-inflight! [^js el k-abort] + (when-let [^js ctrl (du/getv el k-abort)] + (.abort ctrl) + (du/setv-untraced! el k-abort nil))) + +(defn- settle! + "Apply a terminal state, but only if `ctrl` is still the active request. A + stale callback (superseded by a newer request) is a no-op, so an + aborted-but-resolved response can never overwrite fresher state." + [^js el ^js ctrl new-state {:keys [k-abort] :as cfg}] + (when (identical? ctrl (du/getv el k-abort)) + (du/setv-untraced! el k-abort nil) + (set-state! el new-state cfg))) + +(defn- settle-ok-body! + "Resolve the body as text first: a 204/empty body makes `.json` reject — that + is a success with no value → success(nil). A non-empty body that fails to parse + is a genuine error." + [^js el ^js ctrl ^js resp status {:keys [success-fn error-fn] :as cfg}] + (-> (.text resp) + (.then (fn on-text [^js text] + (settle! el ctrl + (success-fn (when (pos? (.-length (.trim text))) (js/JSON.parse text)) status) + cfg))) + (.catch (fn on-parse-error [^js err] + (settle! el ctrl (error-fn (str "Invalid JSON: " (.-message err)) status) cfg))))) + +(defn- handle-response! [^js el ^js ctrl ^js resp {:keys [error-fn] :as cfg}] + (let [status (.-status resp)] + (if (.-ok resp) + (settle-ok-body! el ctrl resp status cfg) + (settle! el ctrl (error-fn (str "HTTP " status) status) cfg)))) + +(defn- handle-network-error! [^js el ^js ctrl ^js err {:keys [error-fn] :as cfg}] + ;; An aborted fetch rejects with AbortError — intentional teardown, not an error. + (when-not (= "AbortError" (.-name err)) + (settle! el ctrl (error-fn (.-message err) nil) cfg))) + +(defn start-fetch! + "Abort any in-flight request, publish `loading-state`, then fetch `url` with + `init` (a JS fetch-init object) and wire the response/error handlers through the + shared settle path. The caller builds `url`/`init` (GET vs POST/JSON-body) and + supplies the in-flight state value to publish." + [^js el url init loading-state {:keys [k-abort] :as cfg}] + (let [ctrl (js/AbortController.) + signal (.-signal ctrl) + ;; Merge the signal into a FRESH object — never mutate the caller's `init` + ;; literal (it is a value, not a place: the caller can't tell from the call + ;; site that we'd write into it). The signal is ours to add; their init stays clean. + init* (js/Object.assign #js {:signal signal} init)] + (abort-inflight! el k-abort) + (du/setv-untraced! el k-abort ctrl) + (set-state! el loading-state cfg) + (-> (js/fetch url init*) + (.then (fn on-response [^js resp] (handle-response! el ctrl resp cfg))) + (.catch (fn on-error [^js err] (handle-network-error! el ctrl err cfg)))))) diff --git a/src/baredom/components/barebuild/listeners.cljs b/src/baredom/components/barebuild/listeners.cljs new file mode 100644 index 00000000..85f969a4 --- /dev/null +++ b/src/baredom/components/barebuild/listeners.cljs @@ -0,0 +1,37 @@ +(ns baredom.components.barebuild.listeners + (:require + [baredom.utils.dom :as du])) + +;; Shared source-listener management for the BareBuild family — barebuild-action +;; (a host-bound submit listener) and barebuild-invalidate-on (a parentNode-bound +;; source listener). Both stash a stable handler, the node it was bound to, and the +;; event name, so a rebind (attribute change) or a disconnect removes the EXACT +;; (node, event, handler) triple it added. The mechanics are identical; only the +;; target node and the event-name resolution differ, so those stay in the caller. +;; This is kept separate from barebuild/lifecycle (the fetch + state-transition +;; concern) so neither namespace mixes two responsibilities. `cfg` keys: +;; :k-handler instance-field key holding the stable handler closure +;; :k-bound-event instance-field key holding the event name currently bound +;; :k-node OPTIONAL key holding the node attached to (defaults to `el`) + +(defn detach-listener! + "Remove a previously-bound source listener (no-op when nothing is bound). With + :k-node configured the node is read from that field; without it the listener is + assumed to sit on `el` itself." + [^js el {:keys [k-handler k-node k-bound-event]}] + (let [^js h (du/getv el k-handler) + ^js node (if k-node (du/getv el k-node) el) + bound (du/getv el k-bound-event)] + (when (and h node bound) + (.removeEventListener node bound h) + (when k-node (du/setv! el k-node nil)) + (du/setv! el k-bound-event nil)))) + +(defn bind-listener! + "Attach `handler` to `node` for `event-name`, stashing the bound-event (and the + node, when :k-node is configured) so detach-listener! can later remove the exact + pair. The caller resolves `node` + `event-name` and supplies the stable handler." + [^js el ^js node event-name handler {:keys [k-node k-bound-event]}] + (.addEventListener node event-name handler) + (when k-node (du/setv! el k-node node)) + (du/setv! el k-bound-event event-name)) diff --git a/src/baredom/components/barebuild/protocol.cljs b/src/baredom/components/barebuild/protocol.cljs new file mode 100644 index 00000000..bfc60fa4 --- /dev/null +++ b/src/baredom/components/barebuild/protocol.cljs @@ -0,0 +1,22 @@ +(ns baredom.components.barebuild.protocol) + +;; Single source of truth for the cross-component HANDSHAKE of the BareBuild +;; server-state family — the event names AND the shared `.state` field key. These +;; strings are the wire between independently shipped ESM modules: barebuild-action +;; emits `event-action-state` and barebuild-data emits `event-data-state`; +;; barebuild-invalidate-on emits `event-invalidate` which barebuild-data listens +;; for. Each module ships separately, so a rename has to land on every side at once +;; — declaring the strings here once makes that handshake compile-coupled instead of +;; hand-kept copies that drift silently (build stays green, invalidation just stops +;; working at runtime). Each model.cljs re-exports the names it uses so component +;; code keeps reading `model/…`. + +(def event-data-state "barebuild-data-state") +(def event-action-state "barebuild-action-state") +(def event-invalidate "barebuild-invalidate") + +;; The `.state` object's shared field key. Both barebuild-data (loaded/error) and +;; barebuild-action (success/error) stamp `httpStatus` onto their state via gobj/set +;; (a string key — `.state` is a plain JS object); declaring it here keeps the two +;; model constructors from re-spelling the literal independently. +(def field-http-status "httpStatus") diff --git a/src/baredom/components/barebuild_action/barebuild_action.cljs b/src/baredom/components/barebuild_action/barebuild_action.cljs new file mode 100644 index 00000000..e5c10720 --- /dev/null +++ b/src/baredom/components/barebuild_action/barebuild_action.cljs @@ -0,0 +1,197 @@ +(ns baredom.components.barebuild-action.barebuild-action + (:require + [clojure.string :as str] + [goog.object :as gobj] + [baredom.utils.component :as component] + [baredom.utils.dom :as du] + [baredom.components.barebuild.lifecycle :as lifecycle] + [baredom.components.barebuild.listeners :as listeners] + [baredom.components.barebuild-action.model :as model])) + +;; Effect shell for barebuild-action. Owns no authoritative state — the server +;; does. It listens for the configured `submit-event` on its HOST (catching +;; bubbling events from any descendant emitter — containment, no selectors), +;; fetches, caches the resulting state value (a plain JS object), and dispatches +;; `barebuild-action-state {:name :state}` on every phase transition. Containment +;; only — it never reaches into the DOM by lookup. + +;; ── Instance-field keys ──────────────────────────────────────────────────────── +(def ^:private k-initialized? "__barebuildActionInit") +(def ^:private k-state "__barebuildActionState") ; cached JS state object +;; k-abort holds the in-flight AbortController, written via du/setv-untraced!: an +;; opaque non-cloneable handle with no diagnostic display value; the meaningful +;; lifecycle (submitting→success/error) is already traced via k-state + the event. +(def ^:private k-abort "__barebuildActionAbort") ; in-flight AbortController (untraced) +(def ^:private k-submit-handler "__barebuildActionSubmitHandler") ; stable host listener +(def ^:private k-bound-event "__barebuildActionBoundEvent") ; submit-event name currently bound + +;; Shared resting value so pre-connect `.state` reads return a stable reference. +;; Frozen (every constructor freezes), so this singleton cannot be mutated. +(def ^:private default-idle (model/idle-state)) + +;; The action WRAPS visible content (the submit emitter, e.g. an ) by +;; containment, so its shadow root MUST carry a (ensure-shadow! slot? = true) +;; or the wrapped content has nowhere to project and never renders — even though it +;; stays in the light DOM and functions. display:block makes the host a transparent +;; block wrapper around that slot. (invalidate-on is a childless leaf → no slot, +;; display:none.) +(def ^:private styles ":host{display:block}") + +;; ── Shadow DOM (a transparent wrapper around the emitter) ───────────────── +(defn- ensure-shadow! [^js el] + (du/ensure-shadow-with-style! el styles k-initialized? true)) + +;; ── Fetch lifecycle (shared with barebuild-data — see barebuild/lifecycle) ────── +;; All the fetch/settle/change-guard machinery lives in the shared lifecycle ns so +;; the write and read sides cannot drift. This cfg names the three things the write +;; side differs in: the by-reference payload field is `response`, the dispatched +;; event is `barebuild-action-state` carrying `{name, state}` (name echoed at the +;; detail top level so matchers need not traverse state), and success/error use +;; the action-side constructors. +(def ^:private lifecycle-cfg + {:k-state k-state + :k-abort k-abort + :payload-fn (fn action-payload [^js s] (.-response s)) + :event-name model/event-action-state + ;; `name` is trimmed (get-attr-trimmed) so it applies the SAME whitespace policy + ;; as 's `when-name` matcher (also trimmed) — otherwise a + ;; padded name=" x " would never equal a clean when-name="x" and silently disable + ;; name-scoped invalidation. + :detail-fn (fn action-detail [^js el ^js state] + #js {:name (du/get-attr-trimmed el model/attr-name) :state state}) + :success-fn model/success-state + :error-fn model/error-state}) + +(defn- apply-values-transform + "Apply the consumer-supplied `valuesTransform` (a public JS property, `(fn [values] + → values)`) before JSON-encoding, when one is set. This is the seam for payload + hygiene the action cannot itself know — blank-stripping (so an unset control's \"\" + doesn't shadow a server default) or numeric coercion (a number control reports a + string). Absent / non-function → `values` unchanged, so the default is encode-as-is." + [^js el values] + (let [t (.-valuesTransform el)] + (if (fn? t) (t values) values))) + +(defn- action-url + "The submit target: the trimmed `action` URL, or nil when blank/absent (whitespace + = absent, so an untrimmed `\" \"` can't resolve to the current page). Single source + for 'is there a target?' — both the submit guard and the preventDefault decision + read it, so the precondition lives in one calculation, not two." + [^js el] + (du/get-attr-trimmed el model/attr-action)) + +(defn- do-submit! + "Abort any in-flight request and POST/PUT/… `values` (JSON) to `action`. No-ops with a + diagnostic on a blank `action` OR a nil body — the body guard covers a `valuesTransform` + that returns nil AND a `.submit(nil)` imperative call, so neither path can send a + `null`-body or empty-target request. Sole owner of both guards; it is also the + `.submit()` entry point, so the checks cannot move entirely to the event path." + [^js el values] + (let [action (action-url el) + method (or (du/get-attr-trimmed el model/attr-method) model/default-method) + body (apply-values-transform el values)] + (cond + (not action) (js/console.error "barebuild-action: missing `action`; skipping submit") + (nil? body) (js/console.error "barebuild-action: nil body (values or valuesTransform result); skipping (no null-body request)") + :else (lifecycle/start-fetch! el action + #js {:method method + :headers #js {"Content-Type" "application/json"} + :body (js/JSON.stringify body)} + (model/submitting-state) + lifecycle-cfg)))) + +;; ── Submit-event wiring (containment; no selectors) ────────────────────────────── +(defn- parse-path + "Parse a values-path attribute like \"[:values]\" or \"[\\\"a\\\" \\\"b\\\"]\" into a + vector of string keys, WITHOUT cljs.reader (which would bloat this per-component ESM + module by ~40 KB). Strips the surrounding brackets, splits on whitespace, and trims + a leading `:` and surrounding quotes from each token. nil for a blank path." + [raw] + (let [inner (-> raw (str/replace #"^\s*\[" "") (str/replace #"\]\s*$" "") str/trim)] + (when-not (str/blank? inner) + (mapv (fn [tok] (-> tok (str/replace #"^[:\"']+" "") (str/replace #"[\"']+$" ""))) + (str/split inner #"\s+"))))) + +(defn- resolve-values + "Read the values map out of `detail` at `path-attr` (literal vector, default + [:values]), walking the JS object by string key — so `:values` resolves + `detail.values`. Returns nil on a blank path or a miss." + [^js detail path-attr] + (when (some? detail) + (let [keys (parse-path (or path-attr model/default-values-path))] + (when (seq keys) + (apply gobj/getValueByKeys detail keys))))) + +(defn- on-submit-event [^js el ^js ev] + ;; Claim the emitter's event (preventDefault) ONLY when we can service the submit — + ;; otherwise a submit we skip would be silently swallowed (an emitter that branches + ;; on defaultPrevented would wrongly conclude we handled it). Values resolution is + ;; this path's own concern (the .submit() entry gets values explicitly), so it logs + ;; here; the missing-action guard + diagnostic belong to do-submit! alone, so we + ;; only gate preventDefault on `action-url` and delegate — no duplicated guard/log. + (let [values (resolve-values (.-detail ev) (du/get-attr-trimmed el model/attr-values-path))] + (if (nil? values) + (js/console.error "barebuild-action: no values at values-path; skipping (no empty-body request)") + (do (when (action-url el) (.preventDefault ev)) + (do-submit! el values))))) + +;; Listener mechanics (stash/remove/rebind the host submit listener) are shared with +;; barebuild-invalidate-on via barebuild/listeners — only the target (the host `el`, +;; no :k-node) and the required `submit-event` resolution live here. +(def ^:private listener-cfg + {:k-handler k-submit-handler :k-bound-event k-bound-event}) + +(defn- ensure-handler! [^js el] + (or (du/getv el k-submit-handler) + (let [h (fn action-on-submit [^js ev] (on-submit-event el ev))] + (du/setv! el k-submit-handler h) + h))) + +(defn- bind-submit! + "(Re)bind the host listener to the current `submit-event` name. Required: a missing + `submit-event` logs an error and leaves the action inert (never fetches)." + [^js el] + (listeners/detach-listener! el listener-cfg) + (let [evt (du/get-attr-trimmed el model/attr-submit-event)] + (if-not evt + (js/console.error "barebuild-action: missing required `submit-event`; the action will not fetch") + (listeners/bind-listener! el el evt (ensure-handler! el) listener-cfg)))) + +;; ── Lifecycle ───────────────────────────────────────────────────────────────────── +(defn- connected! [^js el] + (ensure-shadow! el) + (du/setv! el k-state default-idle) + (bind-submit! el)) + +(defn- disconnected! [^js el] + (listeners/detach-listener! el listener-cfg) + (lifecycle/abort-inflight! el k-abort) + ;; DELIBERATE event-less idle reset (raw du/setv!, not set-state!): teardown is not + ;; a submit-lifecycle transition, so a detaching action publishes no event. A + ;; consumer driving UI off a mid-submit `submitting` phase must treat disconnect as + ;; terminal itself (mirrors barebuild-data's disconnect contract). + (du/setv! el k-state default-idle)) + +(defn- attribute-changed! [^js el name _old-val _new-val] + ;; Only `submit-event` needs a side effect (rebind the listener). The upgrade's + ;; initial attribute callback fires before connectedCallback, so connected! owns + ;; the first bind. + (when (and (du/initialized? el k-initialized?) (.-isConnected el) + (= name model/attr-submit-event)) + (bind-submit! el))) + +;; ── Property accessors ─────────────────────────────────────────────────────────── +(defn- install-property-accessors! [^js proto] + (du/install-properties! proto model/property-api) ; string reflectors; skips readonly :state + (du/define-readonly-prop! proto "state" + (fn ba-get-state [^js this] (or (du/getv this k-state) default-idle))) + (aset proto "submit" (fn ba-submit [values] (this-as ^js el (do-submit! el values))))) + +;; ── Registration ────────────────────────────────────────────────────────────────── +(defn init! [] + (component/register! model/tag-name + {:observed-attributes model/observed-attributes + :connected-fn connected! + :disconnected-fn disconnected! + :attribute-changed-fn attribute-changed! + :setup-prototype-fn install-property-accessors!})) diff --git a/src/baredom/components/barebuild_action/model.cljs b/src/baredom/components/barebuild_action/model.cljs new file mode 100644 index 00000000..16c92924 --- /dev/null +++ b/src/baredom/components/barebuild_action/model.cljs @@ -0,0 +1,96 @@ +(ns baredom.components.barebuild-action.model + (:require [goog.object :as gobj] + [baredom.components.barebuild.protocol :as protocol])) + +;; barebuild-action wraps a SUBMIT EMITTER by containment: any descendant +;; dispatching a cancelable, bubbling event of the configured `submit-event` +;; name, carrying a values map at `values-path` in `event.detail`. On that event +;; it preventDefault()s, JSON-encodes the values, fetches `action` with `method`, +;; and publishes the HTTP result as ONE value (.state) + a `barebuild-action-state` +;; event per phase transition. It does NOT dispatch invalidation (its child +;; does) and does NOT name x-form anywhere. +;; +;; ALPHA (4.0.0-alpha) — explicitly unstable. Attribute shapes may change before +;; stable; the no-selectors / values-not-places spine will not. +;; +;; State is a PLAIN JS OBJECT (string phase), identical in spirit to +;; barebuild-data's: the per-component ESM ships separate from a consumer's +;; cljs.core, so a CLJS map would carry this module's Keyword class (unreadable +;; by the consumer). A JS object is readable by anyone. + +(def tag-name "barebuild-action") + +(def attr-name "name") +(def attr-action "action") +(def attr-method "method") +(def attr-submit-event "submit-event") +(def attr-values-path "values-path") + +(def observed-attributes + #js [attr-name attr-action attr-method attr-submit-event attr-values-path]) + +;; ── Events ─────────────────────────────────────────────────────────────────── +;; The cross-component name comes from the shared protocol ns (single source of +;; truth — see protocol.cljs), keeping the handshake compile-coupled. +(def event-action-state protocol/event-action-state) ; emitted on every phase transition + +;; ── Defaults ─────────────────────────────────────────────────────────────────── +(def default-method "POST") +;; The default values path. NOT a coupling to x-form: it is the path WITHIN +;; event.detail (any emitter putting its values at detail.values matches), never +;; the event name — `submit-event` is required and names the emitter explicitly. +(def default-values-path "[:values]") + +;; ── Phases ─────────────────────────────────────────────────────────────────── +;; Closed set of phase STRINGS; each named once and reused by `phases` and the +;; constructors, so the set can never drift from what the constructors stamp. +(def phase-idle "idle") +(def phase-submitting "submitting") +(def phase-success "success") +(def phase-error "error") + +(def phases #{phase-idle phase-submitting phase-success phase-error}) + +(def property-api + {:name {:type 'string :reflects-attribute attr-name :default ""} + :action {:type 'string :reflects-attribute attr-action :default ""} + :method {:type 'string :reflects-attribute attr-method :default ""} + :submitEvent {:type 'string :reflects-attribute attr-submit-event :default ""} + :valuesPath {:type 'string :reflects-attribute attr-values-path :default ""} + ;; Imperative-only seam (no attribute): a `(fn [values] → values)` applied to the + ;; payload before JSON-encoding, for blank-stripping / coercion the action can't + ;; itself know. No reflects-attribute, so install-properties! installs no accessor + ;; — it is a plain public JS property the component reads via `.-valuesTransform`. + :valuesTransform {:type 'object} + :state {:type 'object :readonly true}}) + +(def event-schema + {event-action-state {:detail {:name 'string :state 'object}}}) + +(def method-api + {:submit {:args [{:name "values" :type 'object}] :returns 'void}}) + +;; ── State constructors (plain JS objects) ────────────────────────────────────── +;; The action's `name` is carried at the EVENT-detail top level (so matchers read +;; it without traversing state), not duplicated inside state. Keys are present +;; only when meaningful for the phase (absent → undefined, not null-valued). + +(defn idle-state [] (js/Object.freeze #js {:phase phase-idle})) + +(defn submitting-state [] (js/Object.freeze #js {:phase phase-submitting})) + +(defn success-state + "Successful mutation. `response` is the parsed (JS) body (nil for an empty / + 204 body); `status` the HTTP status code." + [response status] + (let [s #js {:phase phase-success :response response}] + (when (some? status) (gobj/set s protocol/field-http-status status)) + (js/Object.freeze s))) + +(defn error-state + "Failed mutation. `message` is human-readable; `status` the HTTP status when one + was received (nil for a transport-level failure)." + [message status] + (let [s #js {:phase phase-error :error message}] + (when (some? status) (gobj/set s protocol/field-http-status status)) + (js/Object.freeze s))) diff --git a/src/baredom/components/barebuild_data/barebuild_data.cljs b/src/baredom/components/barebuild_data/barebuild_data.cljs index 482ab69e..1294d0b7 100644 --- a/src/baredom/components/barebuild_data/barebuild_data.cljs +++ b/src/baredom/components/barebuild_data/barebuild_data.cljs @@ -3,6 +3,7 @@ [baredom.utils.component :as component] [baredom.utils.dom :as du] [baredom.utils.model :as mu] + [baredom.components.barebuild.lifecycle :as lifecycle] [baredom.components.barebuild-data.model :as model])) ;; Effect shell for barebuild-data. The element owns no authoritative state — @@ -22,6 +23,11 @@ ;; barebuild-data-state event, so tracing this handle would add noise, not signal. (def ^:private k-abort "__barebuildDataAbort") ; in-flight AbortController (untraced) (def ^:private k-refresh-handler "__barebuildDataRefreshHandler") ; stashed self-listener +;; k-invalidate-handler holds the document-level `barebuild-invalidate` listener. +;; Unlike the refresh handler (on self, GC'd with the element), this sits on +;; `document`, so it MUST be removed on disconnect or it leaks and keeps firing for +;; a detached broker. Added on connect, removed on disconnect. +(def ^:private k-invalidate-handler "__barebuildDataInvalidateHandler") ;; Shared resting value so pre-connect `.state` reads return a stable reference. ;; This is a SINGLETON returned by reference from every instance's `.state` idle @@ -37,72 +43,20 @@ (defn- ensure-shadow! [^js el] (du/ensure-shadow-with-style! el styles k-initialized? false)) -;; ── State transitions (the epochal change-guard: one event per transition) ────── -(defn- same-state? - "Shallow value-equality for two JS state objects. (We can't use `not=`/`=`: on - JS objects those are identity, so two distinct-but-equal `loading` objects would - read as different and re-fire the event.) `data` is compared by reference — a - fresh fetch yields a new array, which is a real new value worth an event." - [^js a ^js b] - (and (some? a) (some? b) - (= (.-phase a) (.-phase b)) - (identical? (.-data a) (.-data b)) - (= (.-error a) (.-error b)) - (= (.-httpStatus a) (.-httpStatus b)))) - -(defn- set-state! - "Cache `new-state` and dispatch `barebuild-data-state` ONLY when it differs from - the current value (shallow). A refetch while already `loading` re-enters with an - equal `loading` object, so no duplicate event fires — every transition on the - fetch lifecycle path emits exactly one event, never a duplicate. The idle resets - in connected!/disconnected! bypass this path (a direct write): they re-establish - a resting value, not a fetch-lifecycle transition, and emit no event." - [^js el ^js new-state] - (when-not (same-state? new-state (du/getv el k-state)) - (du/setv! el k-state new-state) - (du/dispatch! el model/event-data-state #js {:state new-state}))) - -;; ── Fetch lifecycle ───────────────────────────────────────────────────────────── -(defn- abort-inflight! [^js el] - (when-let [^js ctrl (du/getv el k-abort)] - (.abort ctrl) - (du/setv-untraced! el k-abort nil))) - -(defn- settle! - "Apply a terminal state, but only if `ctrl` is still the active fetch. A stale - callback (its fetch was aborted/superseded by a newer one) is a no-op, so an - aborted-but-already-resolved response can never overwrite fresher state." - [^js el ^js ctrl new-state] - (when (identical? ctrl (du/getv el k-abort)) - (du/setv-untraced! el k-abort nil) - (set-state! el new-state))) - -(defn- settle-ok-body! [^js el ^js ctrl ^js resp status] - ;; Resolve the body as text first, not via `(.json resp)` directly: a successful - ;; response with NO body (HTTP 204/205, or a Content-Length: 0 200) makes `.json` - ;; reject — surfacing a *success* as an `:error` \"Invalid JSON\". An empty (or - ;; whitespace-only) body is a load with no value → loaded(nil). A non-empty body - ;; that fails to parse is still a genuine error (JSON.parse throws → .catch). - (-> (.text resp) - (.then (fn on-text [^js text] - (settle! el ctrl - (if (pos? (.-length (.trim text))) - (model/loaded-state (js/JSON.parse text) status) - (model/loaded-state nil status))))) - (.catch (fn on-parse-error [^js err] - (settle! el ctrl (model/error-state (str "Invalid JSON: " (.-message err)) status)))))) - -(defn- handle-response! [^js el ^js ctrl ^js resp] - (let [status (.-status resp)] - (if (.-ok resp) - (settle-ok-body! el ctrl resp status) - (settle! el ctrl (model/error-state (str "HTTP " status) status))))) - -(defn- handle-network-error! [^js el ^js ctrl ^js err] - ;; An aborted fetch rejects with AbortError — that is an intentional teardown, - ;; not an error state. Ignore it (settle! would no-op anyway: ctrl is stale). - (when-not (= "AbortError" (.-name err)) - (settle! el ctrl (model/error-state (.-message err) nil)))) +;; ── Fetch lifecycle (shared with barebuild-action — see barebuild/lifecycle) ──── +;; All the fetch/settle/change-guard machinery lives in the shared lifecycle ns so +;; the read and write sides cannot drift. This cfg names the three things the read +;; side differs in: the by-reference payload field is `data`, the dispatched event +;; is `barebuild-data-state` carrying only `{state}`, and success/error use the +;; data-side constructors. +(def ^:private lifecycle-cfg + {:k-state k-state + :k-abort k-abort + :payload-fn (fn data-payload [^js s] (.-data s)) + :event-name model/event-data-state + :detail-fn (fn data-detail [_el ^js state] #js {:state state}) + :success-fn model/loaded-state + :error-fn model/error-state}) (defn- fetch! "Read `src`: when present, abort any in-flight request and start a fresh fetch; @@ -110,23 +64,15 @@ a stale `:loaded` value published. A read is an observation in time: re-setting `src` (even to the same value) or dispatching refresh re-reads." [^js el] - (let [raw (du/get-attr el model/attr-src) - ;; Trim first: a whitespace-only `src` (" ") is blank, not a URL — without - ;; this it passes `non-empty-string?` and fetches the page itself. Trimming - ;; also strips accidental surrounding whitespace from a real URL. - src (when (string? raw) (.trim raw))] - ;; Aborting any in-flight request is unconditional — only what happens next - ;; (start a fresh fetch vs. fall back to idle) depends on `src`. - (abort-inflight! el) - (if (mu/non-empty-string? src) - (let [ctrl (js/AbortController.) - signal (.-signal ctrl)] - (du/setv-untraced! el k-abort ctrl) - (set-state! el (model/loading-state)) - (-> (js/fetch src #js {:signal signal}) - (.then (fn on-response [^js resp] (handle-response! el ctrl resp))) - (.catch (fn on-error [^js err] (handle-network-error! el ctrl err))))) - (set-state! el default-idle)))) + ;; get-attr-trimmed centralizes "whitespace = absent": a whitespace-only `src` + ;; (" ") is blank, not a URL — untrimmed it would pass and fetch the page itself. + (let [src (du/get-attr-trimmed el model/attr-src)] + (if src + (lifecycle/start-fetch! el src #js {} (model/loading-state) lifecycle-cfg) + ;; Blank src: abort any in-flight request and fall back to idle (start-fetch! + ;; would have aborted; here we do it explicitly since no fetch follows). + (do (lifecycle/abort-inflight! el k-abort) + (lifecycle/set-state! el default-idle lifecycle-cfg))))) ;; ── Refresh listener (on self; GC'd with the element) ─────────────────────────── (defn- add-refresh-listener! [^js el] @@ -134,11 +80,46 @@ (du/setv! el k-refresh-handler h) (.addEventListener el model/event-data-refresh h))) +;; ── Invalidate listener (on document; matched by URL origin + pathname + query) ── +(defn- match-key + "The match key of `s` resolved against the page origin: `origin + pathname + + search`, or nil for a blank/invalid src. Resolving against the origin makes a + relative `src` and an absolute same-origin one to the same path equal; KEEPING the + origin in the key keeps a cross-origin `https://other/api/x` distinct from the + page's own `/api/x` (dropping it would conflate same-path different-origin + resources). Including the query string keeps two parameterised resources on the + same path distinct (`/items?page=1` ≠ `/items?page=2`) — exactly the equality + `` documents." + [s] + (when (mu/non-empty-string? s) + (try (let [^js u (js/URL. s (.. js/location -origin))] + (str (.-origin u) (.-pathname u) (.-search u))) + (catch :default _ nil)))) + +(defn- on-invalidate [^js el ^js e] + ;; A read is an observation in time: when an invalidation names our src (exact + ;; origin+pathname+query equality), re-read. A blank/mismatched src is ignored. + ;; `barebuild-invalidate` is a PUBLIC protocol any code may dispatch, so read the + ;; foreign src defensively (a detail-less CustomEvent must not throw in this + ;; document listener) — a nil src can never match a non-nil `own`. + (let [own (match-key (du/get-attr el model/attr-src))] + (when (and own (= own (match-key (some-> e .-detail .-src)))) + (fetch! el)))) + +(defn- ensure-invalidate-handler! [^js el] + (or (du/getv el k-invalidate-handler) + (let [h (fn data-on-invalidate [^js e] (on-invalidate el e))] + (du/setv! el k-invalidate-handler h) + h))) + ;; ── Lifecycle ───────────────────────────────────────────────────────────────────── (defn- connected! [^js el] (ensure-shadow! el) (when-not (du/getv el k-refresh-handler) (add-refresh-listener! el)) + ;; Re-add each connect (a no-op if the same listener ref is already attached); + ;; balanced by the removeEventListener in disconnected! so it cannot leak. + (.addEventListener js/document model/event-invalidate (ensure-invalidate-handler! el)) ;; Seed the cache to the resting idle value (a direct write, no event — it is ;; the default the `.state` getter already reports). This keeps a bare connect ;; with no `src` silent (fetch!'s idle fallback sees idle already cached → no @@ -148,8 +129,13 @@ (defn- disconnected! [^js el] ;; The element owns no cache — the server is the truth. Abort and reset to idle; - ;; reconnect re-fetches rather than replaying the stale :loaded value. - (abort-inflight! el) + ;; reconnect re-fetches rather than replaying the stale :loaded value. The idle + ;; reset is a DELIBERATE event-less write (raw du/setv!, not set-state!): teardown + ;; is not a fetch-lifecycle transition, so a detaching broker publishes no event. + ;; A consumer that tracks `phase` must treat disconnect as terminal on its own. + (lifecycle/abort-inflight! el k-abort) + (when-let [^js h (du/getv el k-invalidate-handler)] + (.removeEventListener js/document model/event-invalidate h)) (du/setv! el k-state default-idle)) (defn- attribute-changed! [^js el _name _old-val _new-val] diff --git a/src/baredom/components/barebuild_data/model.cljs b/src/baredom/components/barebuild_data/model.cljs index f3c4922b..280a810c 100644 --- a/src/baredom/components/barebuild_data/model.cljs +++ b/src/baredom/components/barebuild_data/model.cljs @@ -1,5 +1,6 @@ (ns baredom.components.barebuild-data.model - (:require [goog.object :as gobj])) + (:require [goog.object :as gobj] + [baredom.components.barebuild.protocol :as protocol])) ;; barebuild-data fetches a URL and publishes the response as ONE value (.state). ;; This namespace is the pure layer: the closed phase set and the state-value @@ -22,8 +23,11 @@ (def observed-attributes #js [attr-src]) ;; ── Events ─────────────────────────────────────────────────────────────────── -(def event-data-state "barebuild-data-state") ; emitted on every phase transition -(def event-data-refresh "barebuild-data-refresh") ; listened for; user-driven manual refetch +;; The two cross-component names come from the shared protocol ns (single source +;; of truth — see protocol.cljs); refresh is a self-only name, declared locally. +(def event-data-state protocol/event-data-state) ; emitted on every phase transition +(def event-data-refresh "barebuild-data-refresh") ; listened for on self; user-driven manual refetch +(def event-invalidate protocol/event-invalidate) ; listened for at document; refetch when detail.src matches our src ;; ── Phases ─────────────────────────────────────────────────────────────────── ;; The closed set of phase STRINGS, published so a consumer comparing `.phase` @@ -70,7 +74,7 @@ 204 body); `status` the HTTP status code." [data status] (let [s #js {:phase phase-loaded :data data}] - (when (some? status) (gobj/set s "httpStatus" status)) + (when (some? status) (gobj/set s protocol/field-http-status status)) (js/Object.freeze s))) (defn error-state @@ -78,5 +82,5 @@ when one was received (nil for a transport-level failure)." [message status] (let [s #js {:phase phase-error :error message}] - (when (some? status) (gobj/set s "httpStatus" status)) + (when (some? status) (gobj/set s protocol/field-http-status status)) (js/Object.freeze s))) diff --git a/src/baredom/components/barebuild_invalidate_on/barebuild_invalidate_on.cljs b/src/baredom/components/barebuild_invalidate_on/barebuild_invalidate_on.cljs new file mode 100644 index 00000000..0960da65 --- /dev/null +++ b/src/baredom/components/barebuild_invalidate_on/barebuild_invalidate_on.cljs @@ -0,0 +1,115 @@ +(ns baredom.components.barebuild-invalidate-on.barebuild-invalidate-on + (:require + [baredom.utils.component :as component] + [baredom.utils.dom :as du] + [baredom.components.barebuild.listeners :as listeners] + [baredom.components.barebuild-invalidate-on.model :as model])) + +;; Effect shell for barebuild-invalidate-on. Holds only its attributes. Listens to +;; its parentNode (the source, by containment — no selectors) for the configured +;; `event`; on a when-phase/when-name match it dispatches a document-level bubbling +;; `barebuild-invalidate {:src}`. A matching self-matches by URL. + +;; ── Instance-field keys ──────────────────────────────────────────────────────── +(def ^:private k-initialized? "__barebuildInvalidateOnInit") +(def ^:private k-handler "__barebuildInvalidateOnHandler") ; stable source listener +(def ^:private k-source-node "__barebuildInvalidateOnSource") ; node we attached to (for removal) +(def ^:private k-bound-event "__barebuildInvalidateOnBoundEvent") ; event name currently bound + +(def ^:private styles ":host{display:none}") + +(defn- ensure-shadow! [^js el] + (du/ensure-shadow-with-style! el styles k-initialized? false)) + +;; ── Matching (pure) ────────────────────────────────────────────────────────────── +(defn- matches? + "Pure: do the configured matchers all fire on `ev`? when-phase compares + detail.state.phase; when-name compares detail.name; set matchers are AND-composed, + an unset one imposes no constraint. With NEITHER set the element can never match — + that misconfiguration is diagnosed once at connect (`warn-no-matcher!`), not here + per event, so this stays a clean predicate with no I/O." + [^js el ^js ev] + (let [when-phase (du/get-attr-trimmed el model/attr-when-phase) + when-name (du/get-attr-trimmed el model/attr-when-name) + ^js detail (.-detail ev) + phase-ok (or (nil? when-phase) (= when-phase (some-> detail .-state .-phase))) + name-ok (or (nil? when-name) (= when-name (some-> detail .-name)))] + (and (some? (or when-phase when-name)) ; at least one matcher configured + phase-ok name-ok))) + +(defn- warn-no-matcher! + "Config validation, logged ONCE at connect: an element with neither when-phase nor + when-name can never match. Hoisted out of the per-event `matches?` so the predicate + stays pure and the diagnostic doesn't spam on every source event." + [^js el] + (when-not (or (du/get-attr-trimmed el model/attr-when-phase) + (du/get-attr-trimmed el model/attr-when-name)) + (js/console.warn "barebuild-invalidate-on: needs at least one of when-phase / when-name; it will never match"))) + +(defn- do-invalidate! + "Dispatch `barebuild-invalidate {:src}`. A blank `src` is dispatched as-is (the + manual `.invalidate()` trigger is unconditional) but warned about: with no src + no `` can self-match, so a misconfigured wire would otherwise + fail silently." + [^js el] + (let [src (du/get-attr-trimmed el model/attr-src)] + (when (nil? src) + (js/console.warn "barebuild-invalidate-on: blank `src`; the dispatched barebuild-invalidate matches no broker")) + (du/dispatch! el model/event-invalidate #js {:src (or src "")}))) + +(defn- on-source-event [^js el ^js ev] + (when (matches? el ev) + (do-invalidate! el))) + +;; ── Source wiring (containment; no selectors) ──────────────────────────────────── +;; Listener mechanics (stash/remove/rebind) are shared with barebuild-action via +;; barebuild/listeners — here the target is parentNode (so :k-node is configured to +;; stash it) and the event resolves to `event` (default barebuild-action-state). +(def ^:private listener-cfg + {:k-handler k-handler :k-node k-source-node :k-bound-event k-bound-event}) + +(defn- ensure-handler! [^js el] + (or (du/getv el k-handler) + (let [h (fn invalidate-on-source [^js ev] (on-source-event el ev))] + (du/setv! el k-handler h) + h))) + +(defn- bind-source! + "(Re)bind the listener to parentNode for the current `event` name. An orphan + (no parentNode) logs once and no-ops — the element is a child by construction." + [^js el] + (listeners/detach-listener! el listener-cfg) + (let [^js parent (.-parentNode el) + evt (or (du/get-attr-trimmed el model/attr-event) model/default-source-event)] + (if (nil? parent) + (js/console.warn "barebuild-invalidate-on: no parentNode source; no-op") + (listeners/bind-listener! el parent evt (ensure-handler! el) listener-cfg)))) + +;; ── Lifecycle ───────────────────────────────────────────────────────────────────── +(defn- connected! [^js el] + (ensure-shadow! el) + (warn-no-matcher! el) + (bind-source! el)) + +(defn- disconnected! [^js el] + (listeners/detach-listener! el listener-cfg)) + +(defn- attribute-changed! [^js el name _old-val _new-val] + (when (and (du/initialized? el k-initialized?) (.-isConnected el) + (= name model/attr-event)) + (bind-source! el))) + +;; ── Property accessors ─────────────────────────────────────────────────────────── +(defn- install-property-accessors! [^js proto] + (du/install-properties! proto model/property-api) + ;; Manual trigger: dispatch unconditionally, bypassing the matchers + listener. + (aset proto "invalidate" (fn bio-invalidate [] (this-as ^js el (do-invalidate! el))))) + +;; ── Registration ────────────────────────────────────────────────────────────────── +(defn init! [] + (component/register! model/tag-name + {:observed-attributes model/observed-attributes + :connected-fn connected! + :disconnected-fn disconnected! + :attribute-changed-fn attribute-changed! + :setup-prototype-fn install-property-accessors!})) diff --git a/src/baredom/components/barebuild_invalidate_on/model.cljs b/src/baredom/components/barebuild_invalidate_on/model.cljs new file mode 100644 index 00000000..4bc6e138 --- /dev/null +++ b/src/baredom/components/barebuild_invalidate_on/model.cljs @@ -0,0 +1,44 @@ +(ns baredom.components.barebuild-invalidate-on.model + (:require [baredom.components.barebuild.protocol :as protocol])) + +;; barebuild-invalidate-on is placed as a CHILD of a source element (typically +;; ). It listens to the source via parentNode.addEventListener +;; for the configured `event` (default barebuild-action-state); on a match +;; (when-phase against detail.state.phase, when-name against detail.name; at least +;; one required, AND-composed) it dispatches a bubbling `barebuild-invalidate {:src}` +;; at document level. Any with a matching src self-matches (exact +;; URL.pathname equality) and refetches. No selectors: source = parent by +;; construction, receiver matched by URL. +;; +;; ALPHA (4.0.0-alpha) — explicitly unstable. Attribute shapes may change. + +(def tag-name "barebuild-invalidate-on") + +(def attr-event "event") +(def attr-when-phase "when-phase") +(def attr-when-name "when-name") +(def attr-src "src") + +(def observed-attributes + #js [attr-event attr-when-phase attr-when-name attr-src]) + +;; ── Events ─────────────────────────────────────────────────────────────────── +;; Both names are part of the cross-component handshake, so they come from the +;; shared protocol ns (single source of truth — see protocol.cljs). +(def event-invalidate protocol/event-invalidate) ; dispatched on a full matcher match + +;; The default source event. Matches ; override to +;; "barebuild-data-state" or "barebuild-route-change" for other sources. +(def default-source-event protocol/event-action-state) + +(def property-api + {:event {:type 'string :reflects-attribute attr-event :default ""} + :whenPhase {:type 'string :reflects-attribute attr-when-phase :default ""} + :whenName {:type 'string :reflects-attribute attr-when-name :default ""} + :src {:type 'string :reflects-attribute attr-src :default ""}}) + +(def event-schema + {event-invalidate {:detail {:src 'string}}}) + +(def method-api + {:invalidate {:args [] :returns 'void}}) diff --git a/src/baredom/components/x_select/x_select.cljs b/src/baredom/components/x_select/x_select.cljs index 92eee2c6..ee4ac893 100644 --- a/src/baredom/components/x_select/x_select.cljs +++ b/src/baredom/components/x_select/x_select.cljs @@ -398,8 +398,50 @@ ;; Element class and registration ;; --------------------------------------------------------------------------- +(defn- option-value-present? + "True when the inner contract and +;; what every other BareDOM form control honours (x-form's collect-values and direct +;; `el.value` readers depend on it). The reflecting accessor install-properties! would give +;; reads the `value` ATTRIBUTE, which a user selection never updates, so override `value` +;; with a getter that reads the live inner — +;; surface that as pending via the attribute until the option arrives and apply-model! +;; selects it. (`value-attr-not-auto-set-on-change-test` still holds: a user change updates +;; only the inner (defn init! [] (component/register! model/tag-name diff --git a/src/baredom/exports/barebuild_action.cljs b/src/baredom/exports/barebuild_action.cljs new file mode 100644 index 00000000..3802e862 --- /dev/null +++ b/src/baredom/exports/barebuild_action.cljs @@ -0,0 +1,17 @@ +(ns baredom.exports.barebuild-action + (:require + [baredom.components.barebuild-action.barebuild-action :as barebuild-action] + [baredom.components.barebuild-action.model :as model])) + +(defn register! [] + (barebuild-action/init!)) + +(def public-api + {:tag-name model/tag-name + :properties model/property-api + :events model/event-schema + :methods model/method-api + :observed-attributes model/observed-attributes}) + +(defn ^:export init [] + (register!)) diff --git a/src/baredom/exports/barebuild_invalidate_on.cljs b/src/baredom/exports/barebuild_invalidate_on.cljs new file mode 100644 index 00000000..5ce2cd44 --- /dev/null +++ b/src/baredom/exports/barebuild_invalidate_on.cljs @@ -0,0 +1,17 @@ +(ns baredom.exports.barebuild-invalidate-on + (:require + [baredom.components.barebuild-invalidate-on.barebuild-invalidate-on :as barebuild-invalidate-on] + [baredom.components.barebuild-invalidate-on.model :as model])) + +(defn register! [] + (barebuild-invalidate-on/init!)) + +(def public-api + {:tag-name model/tag-name + :properties model/property-api + :events model/event-schema + :methods model/method-api + :observed-attributes model/observed-attributes}) + +(defn ^:export init [] + (register!)) diff --git a/src/baredom/registry.cljs b/src/baredom/registry.cljs index 0c17ffba..9e7de7ae 100644 --- a/src/baredom/registry.cljs +++ b/src/baredom/registry.cljs @@ -10,7 +10,9 @@ Order is alphabetical-by-tag for diff legibility. Registration order is irrelevant: `customElements.define` is independent per tag." (:require + [baredom.exports.barebuild-action :as barebuild-action] [baredom.exports.barebuild-data :as barebuild-data] + [baredom.exports.barebuild-invalidate-on :as barebuild-invalidate-on] [baredom.exports.barebuild-route :as barebuild-route] [baredom.exports.barebuild-router :as barebuild-router] [baredom.exports.x-alert :as x-alert] @@ -234,4 +236,7 @@ the dev demo (`baredom.core/start!`) registers them explicitly." [barebuild-router/register! barebuild-route/register! - barebuild-data/register!]) + barebuild-data/register! + ;; Write-side (ALPHA, 4.0.0-alpha — explicitly unstable). + barebuild-action/register! + barebuild-invalidate-on/register!]) diff --git a/src/baredom/utils/dom.cljs b/src/baredom/utils/dom.cljs index 9d72df8e..6f819467 100644 --- a/src/baredom/utils/dom.cljs +++ b/src/baredom/utils/dom.cljs @@ -67,6 +67,17 @@ [^js el attr-name] (.getAttribute el attr-name)) +(defn get-attr-trimmed + "Like `get-attr`, but trims surrounding whitespace and returns nil for an + absent-or-blank attribute — one home for the 'whitespace = absent' policy the + server-state brokers each re-spelled. (`mu/non-empty-string?` deliberately does + NOT trim, so a `\" \"` attribute would otherwise read as present-but-useless.)" + [^js el attr-name] + (let [v (.getAttribute el attr-name)] + (when (string? v) + (let [t (.trim v)] + (when-not (= "" t) t))))) + (defn set-attr! [^js el attr-name value] (.setAttribute el attr-name value) diff --git a/test/baredom/components/barebuild_action/barebuild_action_test.cljs b/test/baredom/components/barebuild_action/barebuild_action_test.cljs new file mode 100644 index 00000000..b953c4c9 --- /dev/null +++ b/test/baredom/components/barebuild_action/barebuild_action_test.cljs @@ -0,0 +1,311 @@ +(ns baredom.components.barebuild-action.barebuild-action-test + (:require + [cljs.test :refer-macros [deftest is use-fixtures async]] + [baredom.components.barebuild-action.barebuild-action :as action] + [baredom.components.barebuild-action.model :as model])) + +(action/init!) + +(def ^:private original-fetch (.-fetch js/window)) +(def ^:private submit-event "test-submit") + +(defn- cleanup! [] + (doseq [n (.querySelectorAll js/document model/tag-name)] + (.remove n)) + (set! (.-fetch js/window) original-fetch)) + +(use-fixtures :each {:before cleanup! :after cleanup!}) + +;; ── Helpers ────────────────────────────────────────────────────────────────── +(defn- make-action [attrs] + (let [el (.createElement js/document model/tag-name)] + (doseq [[k v] attrs] (.setAttribute el k v)) + el)) + +(defn- append-body! [^js el] + (.appendChild (.-body js/document) el) + el) + +;; The action reads the body via `.text` (then JSON.parse), so an empty body is a +;; success-with-no-value rather than a parse error — the stub mirrors that. +(defn- json-response [data status ok?] + #js {:ok ok? + :status status + :text (fn [] (js/Promise.resolve (if (some? data) (js/JSON.stringify data) "")))}) + +(defn- stub-ok! [data status] + (set! (.-fetch js/window) + (fn [_url _opts] (js/Promise.resolve (json-response data status true))))) + +(defn- after-settle [f] (js/setTimeout f 0)) + +(defn- fire-submit! + "Dispatch the configured submit-event from a descendant so it bubbles to the + action host (the action listens on its host — containment, no selectors)." + [^js el values] + (.dispatchEvent el (js/CustomEvent. submit-event + #js {:bubbles true :cancelable true + :detail #js {:values values}}))) + +(defn- collect-phases! [^js el a] + (.addEventListener el model/event-action-state + (fn [^js e] (swap! a conj (.-phase (.. e -detail -state)))))) + +;; ── Submit → success ────────────────────────────────────────────────────────── +(deftest submit-fetches-and-succeeds-test + (async done + (stub-ok! #js {:created true} 201) + (let [el (make-action {"name" "add" "action" "/api/things" "submit-event" submit-event}) + phases (atom [])] + (collect-phases! el phases) + (append-body! el) + (fire-submit! el #js {:title "x"}) + (after-settle + (fn [] + (is (= ["submitting" "success"] @phases) "emits submitting then success, in order") + (let [^js st (.-state el)] + (is (= "success" (.-phase st))) + (is (= true (.-created ^js (.-response st))) "response carries the parsed body") + (is (= 201 (.-httpStatus st)))) + (done)))))) + +(deftest detail-carries-name-and-state-test + (async done + (stub-ok! #js {} 200) + (let [el (make-action {"name" "add-task" "action" "/api/x" "submit-event" submit-event}) + detail (atom nil)] + (.addEventListener el model/event-action-state (fn [^js e] (reset! detail (.-detail e)))) + (append-body! el) + (fire-submit! el #js {:a 1}) + (after-settle + (fn [] + (is (= "add-task" (.-name ^js @detail)) "name echoed at detail top level") + (is (some? (.-state ^js @detail)) "detail carries state") + (done)))))) + +(deftest posts-json-body-from-values-path-test + (async done + (let [captured (atom nil)] + (set! (.-fetch js/window) + (fn [_url ^js opts] (reset! captured opts) + (js/Promise.resolve (json-response #js {} 200 true)))) + (let [el (make-action {"name" "n" "action" "/api/x" "submit-event" submit-event})] + (append-body! el) + (fire-submit! el #js {:title "hello"}) + (after-settle + (fn [] + (is (= "POST" (.-method ^js @captured)) "defaults to POST") + (is (= "{\"title\":\"hello\"}" (.-body ^js @captured)) "body is the JSON of detail.values") + (done))))))) + +(deftest custom-method-and-values-path-test + (async done + (let [captured (atom nil)] + (set! (.-fetch js/window) + (fn [_url ^js opts] (reset! captured opts) + (js/Promise.resolve (json-response #js {} 200 true)))) + (let [el (make-action {"name" "n" "action" "/api/x" "method" "PUT" + "submit-event" submit-event "values-path" "[:payload]"})] + (append-body! el) + (.dispatchEvent el (js/CustomEvent. submit-event + #js {:bubbles true :cancelable true + :detail #js {:payload #js {:k "v"}}})) + (after-settle + (fn [] + (is (= "PUT" (.-method ^js @captured))) + (is (= "{\"k\":\"v\"}" (.-body ^js @captured)) "values read from detail.payload") + (done))))))) + +;; ── Whitespace / missing action: no fetch (finding #1) ────────────────────────── +(deftest whitespace-action-does-not-fetch-test + (async done + (let [calls (atom 0)] + (set! (.-fetch js/window) + (fn [_ _] (swap! calls inc) (js/Promise.resolve (json-response #js {} 200 true)))) + (let [el (make-action {"name" "n" "action" " " "submit-event" submit-event})] + (append-body! el) + (fire-submit! el #js {:a 1}) + (after-settle + (fn [] + (is (= 0 @calls) "a whitespace-only action is blank — no fetch (never POSTs to the page)") + (is (= "idle" (.-phase (.-state el))) "state stays idle") + (done))))))) + +(deftest missing-action-does-not-fetch-test + (async done + (let [calls (atom 0)] + (set! (.-fetch js/window) + (fn [_ _] (swap! calls inc) (js/Promise.resolve (json-response #js {} 200 true)))) + (let [el (make-action {"name" "n" "submit-event" submit-event})] + (append-body! el) + (fire-submit! el #js {:a 1}) + (after-settle + (fn [] + (is (= 0 @calls) "a missing action no-ops") + (done))))))) + +(deftest no-values-at-path-does-not-fetch-test + (async done + (let [calls (atom 0)] + (set! (.-fetch js/window) + (fn [_ _] (swap! calls inc) (js/Promise.resolve (json-response #js {} 200 true)))) + (let [el (make-action {"name" "n" "action" "/api/x" "submit-event" submit-event})] + (append-body! el) + ;; detail with no `values` key → resolve-values misses → no empty-body POST + (.dispatchEvent el (js/CustomEvent. submit-event + #js {:bubbles true :cancelable true :detail #js {:other 1}})) + (after-settle + (fn [] + (is (= 0 @calls) "no values at values-path → no fetch") + (done))))))) + +;; ── Errors ─────────────────────────────────────────────────────────────────── +(deftest http-error-test + (async done + (set! (.-fetch js/window) (fn [_ _] (js/Promise.resolve (json-response nil 500 false)))) + (let [el (make-action {"name" "n" "action" "/api/x" "submit-event" submit-event})] + (append-body! el) + (fire-submit! el #js {:a 1}) + (after-settle + (fn [] + (let [^js st (.-state el)] + (is (= "error" (.-phase st))) + (is (= "HTTP 500" (.-error st))) + (is (= 500 (.-httpStatus st)))) + (done)))))) + +(deftest network-error-test + (async done + (set! (.-fetch js/window) (fn [_ _] (js/Promise.reject (js/Error. "boom")))) + (let [el (make-action {"name" "n" "action" "/api/x" "submit-event" submit-event})] + (append-body! el) + (fire-submit! el #js {:a 1}) + (after-settle + (fn [] + (let [^js st (.-state el)] + (is (= "error" (.-phase st))) + (is (= "boom" (.-error st))) + (is (nil? (.-httpStatus st)) "no http-status for a transport failure")) + (done)))))) + +;; ── 204 / empty body is a success(nil), not a parse error ─────────────────────── +(deftest empty-body-succeeds-with-nil-response-test + (async done + (set! (.-fetch js/window) + (fn [_ _] (js/Promise.resolve #js {:ok true :status 204 :text (fn [] (js/Promise.resolve ""))}))) + (let [el (make-action {"name" "n" "action" "/api/x" "submit-event" submit-event})] + (append-body! el) + (fire-submit! el #js {:a 1}) + (after-settle + (fn [] + (let [^js st (.-state el)] + (is (= "success" (.-phase st)) "a 204/empty body is a success, never a parse error") + (is (nil? (.-response st)) "an empty body carries nil response") + (is (= 204 (.-httpStatus st)))) + (done)))))) + +;; ── Abort on disconnect ───────────────────────────────────────────────────────── +(deftest abort-on-disconnect-test + (async done + (let [captured (atom nil)] + (set! (.-fetch js/window) + (fn [_ ^js opts] (reset! captured (.-signal opts)) (js/Promise. (fn [_ _])))) + (let [el (make-action {"name" "n" "action" "/api/x" "submit-event" submit-event})] + (append-body! el) + (fire-submit! el #js {:a 1}) + (after-settle + (fn [] + (is (false? (.-aborted ^js @captured)) "not aborted while in-flight") + (.remove el) + (is (true? (.-aborted ^js @captured)) "in-flight fetch aborted on disconnect") + (done))))))) + +;; ── Stale response cannot overwrite fresher state (settle! identity guard) ─────── +(deftest stale-response-does-not-overwrite-test + (async done + (let [resolvers (atom [])] + (set! (.-fetch js/window) + (fn [_ _] (js/Promise. (fn [resolve _reject] (swap! resolvers conj resolve))))) + (let [el (make-action {"name" "n" "action" "/api/x" "submit-event" submit-event})] + (append-body! el) + (fire-submit! el #js {:n 1}) ; submit A pending + (after-settle + (fn [] + (fire-submit! el #js {:n 2}) ; submit B aborts A + (after-settle + (fn [] + (let [[resolve-a resolve-b] @resolvers] + (is (= 2 (count @resolvers)) "two submits issued") + (resolve-b (json-response #js {:which "B"} 200 true)) ; B settles first + (resolve-a (json-response #js {:which "A"} 200 true)) ; A lands LAST, must no-op + (after-settle + (fn [] + (is (= "B" (.-which ^js (.-response (.-state el)))) + "the superseded submit A's later response is ignored") + (done)))))))))))) + +;; ── .submit() method bypasses the listener ────────────────────────────────────── +(deftest submit-method-bypasses-listener-test + (async done + (let [captured (atom nil)] + (set! (.-fetch js/window) + (fn [_ ^js opts] (reset! captured opts) (js/Promise.resolve (json-response #js {} 200 true)))) + (let [^js el (make-action {"name" "n" "action" "/api/x" "submit-event" submit-event})] + (append-body! el) + (.submit el #js {:imperative true}) + (after-settle + (fn [] + (is (= "{\"imperative\":true}" (.-body ^js @captured)) ".submit(values) posts directly") + (done))))))) + +;; ── valuesTransform applied before JSON-encode (payload-hygiene seam) ──────────── +(deftest values-transform-applied-test + (async done + (let [captured (atom nil)] + (set! (.-fetch js/window) + (fn [_ ^js opts] (reset! captured opts) + (js/Promise.resolve (json-response #js {} 200 true)))) + (let [^js el (make-action {"name" "n" "action" "/api/x" "submit-event" submit-event})] + ;; A transform that drops blank-string values (the without-blanks job). + (set! (.-valuesTransform el) + (fn drop-blanks [^js v] + (let [out #js {}] + (doseq [k (js/Object.keys v)] + (when-not (= "" (aget v k)) (aset out k (aget v k)))) + out))) + (append-body! el) + (fire-submit! el #js {:title "hi" :status ""}) + (after-settle + (fn [] + (is (= "{\"title\":\"hi\"}" (.-body ^js @captured)) + "valuesTransform runs before JSON-encode (blank status dropped)") + (done))))))) + +;; ── detail.name is trimmed (same whitespace policy as invalidate-on's when-name) ── +(deftest detail-name-is-trimmed-test + (async done + (stub-ok! #js {} 200) + (let [el (make-action {"name" " add-task " "action" "/api/x" "submit-event" submit-event}) + detail (atom nil)] + (.addEventListener el model/event-action-state (fn [^js e] (reset! detail (.-detail e)))) + (append-body! el) + (fire-submit! el #js {:a 1}) + (after-settle + (fn [] + (is (= "add-task" (.-name ^js @detail)) "detail.name is trimmed") + (done)))))) + +;; ── A submit we won't service is not swallowed (default not prevented) ─────────── +(deftest unserviced-submit-does-not-prevent-default-test + (async done + (set! (.-fetch js/window) (fn [_ _] (js/Promise.resolve (json-response #js {} 200 true)))) + (let [el (make-action {"name" "n" "action" "/api/x" "submit-event" submit-event}) + ev (js/CustomEvent. submit-event + #js {:bubbles true :cancelable true :detail #js {:other 1}})] + (append-body! el) + (.dispatchEvent el ev) ; no `values` at the path → not serviced + (after-settle + (fn [] + (is (false? (.-defaultPrevented ev)) + "no preventDefault when there are no values to submit (emitter's default proceeds)") + (done)))))) diff --git a/test/baredom/components/barebuild_action/model_test.cljs b/test/baredom/components/barebuild_action/model_test.cljs new file mode 100644 index 00000000..f5b2b1b3 --- /dev/null +++ b/test/baredom/components/barebuild_action/model_test.cljs @@ -0,0 +1,61 @@ +(ns baredom.components.barebuild-action.model-test + (:require [cljs.test :refer-macros [deftest is testing]] + [goog.object :as gobj] + [baredom.components.barebuild.protocol :as protocol] + [baredom.components.barebuild-action.model :as model])) + +;; State values are plain JS objects (cross-cljs.core-runtime readable), so the +;; tests read them with JS interop, and phases are strings. + +(deftest phases-closed-set-test + (is (= #{"idle" "submitting" "success" "error"} model/phases))) + +(deftest event-name-from-protocol-test + (is (= protocol/event-action-state model/event-action-state) + "the *-state event name is sourced from the shared protocol ns, not a local literal")) + +(deftest idle-state-test + (is (= "idle" (.-phase (model/idle-state))))) + +(deftest submitting-state-test + (is (= "submitting" (.-phase (model/submitting-state))))) + +(deftest success-state-test + (testing "carries response and httpStatus" + (let [resp #js {:id 7} + s (model/success-state resp 201)] + (is (= "success" (.-phase s))) + (is (identical? resp (.-response s))) + (is (= 201 (.-httpStatus s))))) + (testing "response may be nil (204/empty body) and is still a success" + (let [s (model/success-state nil 204)] + (is (= "success" (.-phase s))) + (is (nil? (.-response s))) + (is (= 204 (.-httpStatus s))))) + (testing "httpStatus omitted when nil (absent → undefined, not null-valued)" + (is (not (gobj/containsKey (model/success-state #js {} nil) "httpStatus"))))) + +(deftest error-state-test + (testing "carries message and httpStatus" + (let [s (model/error-state "HTTP 500" 500)] + (is (= "error" (.-phase s))) + (is (= "HTTP 500" (.-error s))) + (is (= 500 (.-httpStatus s))))) + (testing "httpStatus omitted for transport-level failure" + (let [s (model/error-state "boom" nil)] + (is (= "boom" (.-error s))) + (is (not (gobj/containsKey s "httpStatus"))))) + (testing "no response key on error" + (is (not (gobj/containsKey (model/error-state "x" 404) "response"))))) + +;; Every state wrapper is frozen so the cached `.state` value a consumer reads is +;; read-only (see model.cljs). Drop a freeze from any constructor and this goes red. +(deftest state-values-are-frozen-test + (testing "all four constructors return a frozen wrapper" + (is (js/Object.isFrozen (model/idle-state))) + (is (js/Object.isFrozen (model/submitting-state))) + (is (js/Object.isFrozen (model/success-state #js {} 201))) + (is (js/Object.isFrozen (model/error-state "boom" 500)))) + (testing "frozen with or without an optional httpStatus key" + (is (js/Object.isFrozen (model/success-state nil nil))) + (is (js/Object.isFrozen (model/error-state "boom" nil))))) diff --git a/test/baredom/components/barebuild_data/barebuild_data_test.cljs b/test/baredom/components/barebuild_data/barebuild_data_test.cljs index 67f826a7..50d836af 100644 --- a/test/baredom/components/barebuild_data/barebuild_data_test.cljs +++ b/test/baredom/components/barebuild_data/barebuild_data_test.cljs @@ -122,6 +122,34 @@ (is (= 2 @calls) "barebuild-data-refresh triggers a refetch") (done))))))))) +;; ── Change guard: distinct payloads re-fire ────────────────────────────────────── +(deftest distinct-payloads-refire-test + ;; The change guard compares the payload BY REFERENCE: two `loaded` values that + ;; share phase+httpStatus but carry DIFFERENT bodies are distinct and must each + ;; fire. This is the exact path a mis-addressed payload field would break — a + ;; loose string key reading `undefined` on both sides would compare them equal + ;; and silently suppress the second event. Guards the `:payload-fn` accessor. + (async done + (let [el (make-data "/api/x") + loaded (atom [])] + (.addEventListener el model/event-data-state + (fn [^js e] + (let [^js st (.. e -detail -state)] + (when (= "loaded" (.-phase st)) + (swap! loaded conj (.-data st)))))) + (stub-fetch-ok! #js {:v "A"} 200) ; first load: body A + (append-body! el) + (after-settle + (fn [] + (stub-fetch-ok! #js {:v "B"} 200) ; second load: DIFFERENT body, SAME status + (.dispatchEvent el (js/CustomEvent. model/event-data-refresh)) + (after-settle + (fn [] + (is (= 2 (count @loaded)) "two distinct payloads each fire a loaded event") + (is (= "A" (.-v ^js (first @loaded)))) + (is (= "B" (.-v ^js (second @loaded))) "the second, distinct body re-fired (not deduped)") + (done)))))))) + ;; ── Errors ───────────────────────────────────────────────────────────────────────── (deftest http-error-test (async done @@ -281,6 +309,67 @@ (is (= ["idle"] @phases) "exactly one idle transition event fires") (done)))))))) +;; ── Invalidation matches on pathname + query, so paginated siblings stay distinct ── +;; (Regression guard for the match key: `.pathname` alone collapses `?page=1` and +;; `?page=2` to the same key and cross-invalidates; the key includes the query.) +(defn- fire-invalidate! [src] + (.dispatchEvent js/document (js/CustomEvent. model/event-invalidate #js {:detail #js {:src src}}))) + +(deftest invalidation-distinguishes-query-strings-test + (async done + (let [calls (atom 0)] + (set! (.-fetch js/window) + (fn [_ _] (swap! calls inc) (js/Promise.resolve (json-response #js {} 200 true)))) + (let [el (make-data "/api/items?page=1")] + (append-body! el) + (after-settle + (fn [] + (is (= 1 @calls) "fetched once on connect") + (fire-invalidate! "/api/items?page=2") ; different query → must NOT match + (after-settle + (fn [] + (is (= 1 @calls) "an invalidate for a different query string does not refetch") + (fire-invalidate! "/api/items?page=1") ; same path+query → must match + (after-settle + (fn [] + (is (= 2 @calls) "an invalidate for the exact path+query refetches") + (done))))))))))) + +;; ── Cross-origin invalidate for the same path must NOT match (origin is in the key) ── +(deftest invalidate-cross-origin-does-not-match-test + (async done + (let [calls (atom 0)] + (set! (.-fetch js/window) + (fn [_ _] (swap! calls inc) (js/Promise.resolve (json-response #js {} 200 true)))) + (let [el (make-data "/api/items")] + (append-body! el) + (after-settle + (fn [] + (is (= 1 @calls) "fetched once on connect") + (fire-invalidate! "https://other.example.com/api/items") ; same path, other origin + (after-settle + (fn [] + (is (= 1 @calls) "a cross-origin invalidate for the same path does not refetch") + (done))))))))) + +;; ── Public protocol: a detail-less barebuild-invalidate must not throw the listener ── +(deftest invalidate-with-no-detail-does-not-throw-test + (async done + (let [calls (atom 0)] + (set! (.-fetch js/window) + (fn [_ _] (swap! calls inc) (js/Promise.resolve (json-response #js {} 200 true)))) + (let [el (make-data "/api/items")] + (append-body! el) + (after-settle + (fn [] + (is (= 1 @calls) "fetched once on connect") + ;; `new CustomEvent(type)` → detail===null; any code may dispatch this. + (.dispatchEvent js/document (js/CustomEvent. model/event-invalidate)) + (after-settle + (fn [] + (is (= 1 @calls) "a detail-less invalidate is ignored — no throw, no refetch") + (done))))))))) + ;; ── Reconnect re-fetches, no stale replay ──────────────────────────────────────── (deftest reconnect-refetches-test (async done diff --git a/test/baredom/components/barebuild_invalidate_on/barebuild_invalidate_on_test.cljs b/test/baredom/components/barebuild_invalidate_on/barebuild_invalidate_on_test.cljs new file mode 100644 index 00000000..ec64df35 --- /dev/null +++ b/test/baredom/components/barebuild_invalidate_on/barebuild_invalidate_on_test.cljs @@ -0,0 +1,150 @@ +(ns baredom.components.barebuild-invalidate-on.barebuild-invalidate-on-test + (:require + [cljs.test :refer-macros [deftest is use-fixtures]] + [baredom.components.barebuild-invalidate-on.barebuild-invalidate-on :as io] + [baredom.components.barebuild-invalidate-on.model :as model])) + +(io/init!) + +;; All effects here are synchronous (dispatchEvent runs listeners inline), so no +;; cljs.test/async is needed — but the fixture uses the map form regardless. +(defn- cleanup! [] + (doseq [n (.querySelectorAll js/document model/tag-name)] (.remove n)) + (doseq [n (.querySelectorAll js/document ".bio-test-source")] (.remove n))) + +(use-fixtures :each {:before cleanup! :after cleanup!}) + +;; ── Helpers ────────────────────────────────────────────────────────────────── +(defn- mount-on-source! + "Attach a
source to body, mount an invalidate-on with `attrs` inside it + (so parentNode = the source), and return [source invalidate-on]." + [attrs] + (let [source (.createElement js/document "div") + el (.createElement js/document model/tag-name)] + (set! (.-className source) "bio-test-source") + (doseq [[k v] attrs] (.setAttribute el k v)) + (.appendChild (.-body js/document) source) + (.appendChild source el) + [source el])) + +(defn- fire-source! + "Dispatch the default source event (barebuild-action-state) on `source` with the + given phase/name in detail — exactly the shape an action emits." + [^js source phase nm] + (.dispatchEvent source + (js/CustomEvent. model/default-source-event + #js {:bubbles true + :detail #js {:name nm :state #js {:phase phase}}}))) + +(defn- with-invalidate-listener + "Run `f` with a document-level barebuild-invalidate listener that collects each + event's detail.src into an atom; `f` receives that atom. Removes the listener after." + [f] + (let [a (atom []) + h (fn [^js e] (swap! a conj (.. e -detail -src)))] + (.addEventListener js/document model/event-invalidate h) + (try (f a) + (finally (.removeEventListener js/document model/event-invalidate h))))) + +(defn- with-console + "Run `f` with console.error/warn captured; `f` receives a map of two atoms + {:errors :warns}. Restores the originals after." + [f] + (let [orig-err (.-error js/console) + orig-warn (.-warn js/console) + errors (atom []) + warns (atom [])] + (set! (.-error js/console) (fn [m] (swap! errors conj m))) + (set! (.-warn js/console) (fn [m] (swap! warns conj m))) + (try (f {:errors errors :warns warns}) + (finally (set! (.-error js/console) orig-err) + (set! (.-warn js/console) orig-warn))))) + +;; ── Matching ─────────────────────────────────────────────────────────────────── +(deftest when-phase-match-dispatches-invalidate-test + (with-invalidate-listener + (fn [seen] + (let [[source _] (mount-on-source! {"when-phase" "success" "src" "/api/tasks"})] + (fire-source! source "success" "add") + (is (= ["/api/tasks"] @seen) "a when-phase match dispatches barebuild-invalidate with src"))))) + +(deftest when-phase-mismatch-does-not-dispatch-test + (with-invalidate-listener + (fn [seen] + (let [[source _] (mount-on-source! {"when-phase" "success" "src" "/api/tasks"})] + (fire-source! source "error" "add") + (is (= [] @seen) "no invalidate when the phase does not match"))))) + +(deftest when-name-match-dispatches-test + (with-invalidate-listener + (fn [seen] + (let [[source _] (mount-on-source! {"when-name" "add-task" "src" "/api/tasks"})] + (fire-source! source "success" "add-task") + (is (= ["/api/tasks"] @seen) "a when-name match dispatches"))))) + +(deftest both-matchers-are-anded-test + (with-invalidate-listener + (fn [seen] + (let [[source _] (mount-on-source! {"when-phase" "success" "when-name" "add-task" "src" "/api/tasks"})] + (fire-source! source "success" "other") ; name mismatch + (is (= [] @seen) "AND: phase matches but name does not → no dispatch") + (fire-source! source "success" "add-task") ; both match + (is (= ["/api/tasks"] @seen) "AND: both match → dispatch"))))) + +(deftest no-matcher-set-warns-once-and-no-ops-test + (with-console + (fn [{:keys [warns errors]}] + (with-invalidate-listener + (fn [seen] + ;; Neither matcher set: the config diagnostic is logged ONCE at connect (a + ;; warn, alongside the orphan diagnostic) — NOT per source event. matches? + ;; stays a pure predicate, so firing events logs nothing. + (let [[source _] (mount-on-source! {"src" "/api/tasks"})] + (is (= 1 (count @warns)) "warns once at connect that it will never match") + (fire-source! source "success" "add") + (fire-source! source "success" "add") + (is (= [] @seen) "no invalidate when neither matcher is set") + (is (= 1 (count @warns)) "no per-event spam: still just the one connect warn") + (is (= 0 (count @errors)) "config issue is a warn, not a per-event error"))))))) + +;; ── Whitespace matcher is treated as unset ─────────────────────────────────────── +(deftest whitespace-matcher-is-dead-not-active-test + (with-console + (fn [{:keys [warns]}] + (with-invalidate-listener + (fn [seen] + ;; when-phase=" " trims to "" → treated as unset (get-attr-trimmed). With no + ;; when-name either, that's the both-unset case → connect warn + never matches, + ;; not a silent dead matcher that swallows events. + (let [[source _] (mount-on-source! {"when-phase" " " "src" "/api/tasks"})] + (is (= 1 (count @warns)) "a whitespace matcher counts as unset → connect warn") + (fire-source! source "success" "add") + (is (= [] @seen) "and it never dispatches"))))))) + +;; ── Blank src warns but still dispatches (finding #4) ─────────────────────────── +(deftest blank-src-warns-test + (with-console + (fn [{:keys [warns]}] + (with-invalidate-listener + (fn [seen] + (let [[source _] (mount-on-source! {"when-phase" "success"})] ; no src + (fire-source! source "success" "add") + (is (= [""] @seen) "still dispatches (unconditional), with an empty src") + (is (= 1 (count @warns)) "warns that a blank src matches no broker"))))))) + +;; ── Manual trigger bypasses matchers + listener ───────────────────────────────── +(deftest invalidate-method-dispatches-unconditionally-test + (with-invalidate-listener + (fn [seen] + (let [[_ ^js el] (mount-on-source! {"when-name" "never-matches" "src" "/api/tasks"})] + (.invalidate el) + (is (= ["/api/tasks"] @seen) ".invalidate() dispatches regardless of matchers"))))) + +;; ── Listener cleanup on disconnect ─────────────────────────────────────────────── +(deftest disconnect-removes-source-listener-test + (with-invalidate-listener + (fn [seen] + (let [[source el] (mount-on-source! {"when-phase" "success" "src" "/api/tasks"})] + (.remove el) ; disconnect + (fire-source! source "success" "add") + (is (= [] @seen) "no invalidate after disconnect — the source listener was removed"))))) diff --git a/test/baredom/components/barebuild_invalidate_on/model_test.cljs b/test/baredom/components/barebuild_invalidate_on/model_test.cljs new file mode 100644 index 00000000..bc49260b --- /dev/null +++ b/test/baredom/components/barebuild_invalidate_on/model_test.cljs @@ -0,0 +1,19 @@ +(ns baredom.components.barebuild-invalidate-on.model-test + (:require [cljs.test :refer-macros [deftest is]] + [baredom.components.barebuild.protocol :as protocol] + [baredom.components.barebuild-invalidate-on.model :as model])) + +(deftest tag-name-test + (is (= "barebuild-invalidate-on" model/tag-name))) + +;; The cross-component handshake names must come from the shared protocol ns, not +;; local literals — otherwise a rename on one side drifts silently at runtime. +(deftest event-names-from-protocol-test + (is (= protocol/event-invalidate model/event-invalidate) + "the dispatched invalidate event is the shared protocol name") + (is (= protocol/event-action-state model/default-source-event) + "the default source event is the shared protocol action-state name")) + +(deftest observed-attributes-test + (is (= #{"event" "when-phase" "when-name" "src"} + (set (array-seq model/observed-attributes))))) diff --git a/test/baredom/components/x_select/x_select_test.cljs b/test/baredom/components/x_select/x_select_test.cljs index 9c0cfa3d..54817fbe 100644 --- a/test/baredom/components/x_select/x_select_test.cljs +++ b/test/baredom/components/x_select/x_select_test.cljs @@ -324,6 +324,47 @@ 0)) 0)))) +;; Conforming behaviour: `el.value` reflects the user's CURRENT selection (native