From 9e9b047f16e05ea28b6b586ef2550d06aae407b5 Mon Sep 17 00:00:00 2001 From: Alexander van Elsas <58037137+avanelsas@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:39:28 +0200 Subject: [PATCH 01/12] feat(barebuild): scaffold action + invalidate-on write-side alpha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ALPHA (4.0.0-alpha lane, `alpha` dist-tag) — explicitly unstable write-side coordination, shipped separately from stable so adopters consent to churn. barebuild-bind is deliberately NOT included (see write-side-design-notes.md). New elements (three-layer pattern, golden-sample conformant): - barebuild-action: wraps a submit emitter BY CONTAINMENT (listens on host for the configured `submit-event`, no selectors), reads values at `values-path`, JSON-fetches `action`/`method`, publishes a plain-JS `.state` + a barebuild-action-state {name, state} event per phase transition. AbortController teardown + 204-safe body parse mirror barebuild-data. submit(values) method. - barebuild-invalidate-on: child of its source; listens on parentNode for the source event (default barebuild-action-state); when-phase/when-name AND-matchers (at least one required); dispatches document-level barebuild-invalidate {src}. invalidate() method. Substrate (additive, non-breaking): - barebuild-data now listens at document for barebuild-invalidate and refetches when detail.src matches its own src by exact URL.pathname. Listener added on connect, removed on disconnect (it lives on document, so it must not leak). Build registration: shadow-cljs :lib modules, package.json exports, registry barebuild-registers (kept OUT of the UI kitchen-sink all-registers). Avoided a 40 KB trap: values-path parsing uses a tiny hand-parser, not cljs.reader (which dragged the whole reader into the module — 44 KB → 4.5 KB, now in line with barebuild-data at 3.1 KB). The EDN path DSL was speculative surface the telemetry showed nobody needed anyway. clj-kondo clean; shadow-cljs release lib (Closure Advanced) 0 warnings; du-discipline guard clean. Tests + demo port + alpha docs are the next step. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 10 + shadow-cljs.edn | 12 + .../barebuild_action/barebuild_action.cljs | 205 ++++++++++++++++++ .../components/barebuild_action/model.cljs | 88 ++++++++ .../barebuild_data/barebuild_data.cljs | 32 +++ .../components/barebuild_data/model.cljs | 3 +- .../barebuild_invalidate_on.cljs | 105 +++++++++ .../barebuild_invalidate_on/model.cljs | 41 ++++ src/baredom/exports/barebuild_action.cljs | 17 ++ .../exports/barebuild_invalidate_on.cljs | 17 ++ src/baredom/registry.cljs | 7 +- 11 files changed, 535 insertions(+), 2 deletions(-) create mode 100644 src/baredom/components/barebuild_action/barebuild_action.cljs create mode 100644 src/baredom/components/barebuild_action/model.cljs create mode 100644 src/baredom/components/barebuild_invalidate_on/barebuild_invalidate_on.cljs create mode 100644 src/baredom/components/barebuild_invalidate_on/model.cljs create mode 100644 src/baredom/exports/barebuild_action.cljs create mode 100644 src/baredom/exports/barebuild_invalidate_on.cljs diff --git a/package.json b/package.json index 16548b09..f973dac3 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", 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_action/barebuild_action.cljs b/src/baredom/components/barebuild_action/barebuild_action.cljs new file mode 100644 index 00000000..00d50910 --- /dev/null +++ b/src/baredom/components/barebuild_action/barebuild_action.cljs @@ -0,0 +1,205 @@ +(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.utils.model :as mu] + [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. No querySelector. + +;; ── 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)) + +(def ^:private styles ":host{display:none}") + +;; ── Shadow DOM (non-visual; renders nothing) ──────────────────────────────────── +(defn- ensure-shadow! [^js el] + (du/ensure-shadow-with-style! el styles k-initialized? false)) + +;; ── State transitions (one event per transition) ──────────────────────────────── +(defn- same-state? + "Shallow value-equality for two JS state objects (JS = is identity, so two + distinct-but-equal objects would re-fire). `response` is compared by reference." + [^js a ^js b] + (and (some? a) (some? b) + (= (.-phase a) (.-phase b)) + (identical? (.-response a) (.-response b)) + (= (.-error a) (.-error b)) + (= (.-httpStatus a) (.-httpStatus b)))) + +(defn- set-state! + "Cache `new-state` and dispatch `barebuild-action-state` only when it differs from + the current value (shallow). `name` echoes the action's `name` attribute at the + detail top level so matchers (``) need not traverse state." + [^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-action-state + #js {:name (du/get-attr el model/attr-name) :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 request. A stale + callback (superseded by a newer submit) is a no-op, so an aborted-but-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! + "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] + (-> (.text resp) + (.then (fn on-text [^js text] + (settle! el ctrl + (model/success-state + (when (pos? (.-length (.trim text))) (js/JSON.parse text)) + 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 — intentional teardown, not an error. + (when-not (= "AbortError" (.-name err)) + (settle! el ctrl (model/error-state (.-message err) nil)))) + +(defn- do-submit! + "Abort any in-flight request and POST/PUT/… `values` (JSON) to `action`. A blank + `action` logs and no-ops (never an empty-target request)." + [^js el values] + (let [action (du/get-attr el model/attr-action) + method (let [m (du/get-attr el model/attr-method)] + (if (mu/non-empty-string? m) m model/default-method))] + (if-not (mu/non-empty-string? action) + (js/console.error "barebuild-action: missing `action`; skipping submit") + (let [ctrl (js/AbortController.) + signal (.-signal ctrl)] + (abort-inflight! el) + (du/setv-untraced! el k-abort ctrl) + (set-state! el (model/submitting-state)) + (-> (js/fetch action #js {:method method + :headers #js {"Content-Type" "application/json"} + :body (js/JSON.stringify values) + :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)))))))) + +;; ── 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 [raw (if (mu/non-empty-string? path-attr) path-attr model/default-values-path) + keys (parse-path raw)] + (when (seq keys) + (apply gobj/getValueByKeys detail keys))))) + +(defn- on-submit-event [^js el ^js ev] + (.preventDefault ev) + (let [values (resolve-values (.-detail ev) (du/get-attr el model/attr-values-path))] + (if (some? values) + (do-submit! el values) + (js/console.error "barebuild-action: no values at values-path; skipping (no empty-body request)")))) + +(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- detach-listener! [^js el] + (let [^js h (du/getv el k-submit-handler) + bound (du/getv el k-bound-event)] + (when (and h (mu/non-empty-string? bound)) + (.removeEventListener el bound h) + (du/setv! el k-bound-event nil)))) + +(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] + (detach-listener! el) + (let [evt (du/get-attr el model/attr-submit-event)] + (if-not (mu/non-empty-string? evt) + (js/console.error "barebuild-action: missing required `submit-event`; the action will not fetch") + (do (.addEventListener el evt (ensure-handler! el)) + (du/setv! el k-bound-event evt))))) + +;; ── Lifecycle ───────────────────────────────────────────────────────────────────── +(defn- connected! [^js el] + (ensure-shadow! el) + (du/setv! el k-state default-idle) + (bind-submit! el)) + +(defn- disconnected! [^js el] + (detach-listener! el) + (abort-inflight! el) + (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..3c8d71ec --- /dev/null +++ b/src/baredom/components/barebuild_action/model.cljs @@ -0,0 +1,88 @@ +(ns baredom.components.barebuild-action.model + (:require [goog.object :as gobj])) + +;; 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 ─────────────────────────────────────────────────────────────────── +(def event-action-state "barebuild-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 ""} + :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 "httpStatus" 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 "httpStatus" 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..af9aff7b 100644 --- a/src/baredom/components/barebuild_data/barebuild_data.cljs +++ b/src/baredom/components/barebuild_data/barebuild_data.cljs @@ -22,6 +22,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 @@ -134,11 +139,36 @@ (du/setv! el k-refresh-handler h) (.addEventListener el model/event-data-refresh h))) +;; ── Invalidate listener (on document; matched by URL.pathname) ─────────────────── +(defn- url-pathname + "The pathname of `s` resolved against the page origin, or nil for a blank/invalid + src. Invalidation matches on pathname so a relative `src` and an absolute one to + the same path are equal." + [s] + (when (mu/non-empty-string? s) + (try (.-pathname (js/URL. s (.. js/location -origin))) (catch :default _ nil)))) + +(defn- on-invalidate [^js el ^js e] + ;; A read is an observation in time: when an invalidation names our src (exact + ;; pathname equality), re-read. A blank/mismatched src is ignored. + (let [own (url-pathname (du/get-attr el model/attr-src))] + (when (and own (= own (url-pathname (.. 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 @@ -150,6 +180,8 @@ ;; 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) + (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..ac25cbdc 100644 --- a/src/baredom/components/barebuild_data/model.cljs +++ b/src/baredom/components/barebuild_data/model.cljs @@ -23,7 +23,8 @@ ;; ── 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 +(def event-data-refresh "barebuild-data-refresh") ; listened for on self; user-driven manual refetch +(def event-invalidate "barebuild-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` 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..5dc4d653 --- /dev/null +++ b/src/baredom/components/barebuild_invalidate_on/barebuild_invalidate_on.cljs @@ -0,0 +1,105 @@ +(ns baredom.components.barebuild-invalidate-on.barebuild-invalidate-on + (:require + [baredom.utils.component :as component] + [baredom.utils.dom :as du] + [baredom.utils.model :as mu] + [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 ──────────────────────────────────────────────────────────────────── +(defn- matches? + "True when the configured matchers all fire on `ev`. when-phase compares + detail.state.phase; when-name compares detail.name. At least one matcher must be + set (both unset → log + no-match); set matchers are AND-composed." + [^js el ^js ev] + (let [when-phase (du/get-attr el model/attr-when-phase) + when-name (du/get-attr el model/attr-when-name) + phase-set? (mu/non-empty-string? when-phase) + name-set? (mu/non-empty-string? when-name)] + (if-not (or phase-set? name-set?) + (do (js/console.error "barebuild-invalidate-on: needs at least one of when-phase / when-name; no-op") + false) + (let [^js detail (.-detail ev) + phase-ok (or (not phase-set?) (= when-phase (some-> detail .-state .-phase))) + name-ok (or (not name-set?) (= when-name (some-> detail .-name)))] + (and phase-ok name-ok))))) + +(defn- do-invalidate! [^js el] + (du/dispatch! el model/event-invalidate #js {:src (du/get-attr el model/attr-src)})) + +(defn- on-source-event [^js el ^js ev] + (when (matches? el ev) + (do-invalidate! el))) + +;; ── Source wiring (containment; no selectors) ──────────────────────────────────── +(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- detach! [^js el] + (let [^js h (du/getv el k-handler) + ^js node (du/getv el k-source-node) + bound (du/getv el k-bound-event)] + (when (and h node (mu/non-empty-string? bound)) + (.removeEventListener node bound h) + (du/setv! el k-source-node nil) + (du/setv! el k-bound-event nil)))) + +(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] + (detach! el) + (let [^js parent (.-parentNode el) + evt (let [e (du/get-attr el model/attr-event)] + (if (mu/non-empty-string? e) e model/default-source-event))] + (if (nil? parent) + (js/console.warn "barebuild-invalidate-on: no parentNode source; no-op") + (do (.addEventListener parent evt (ensure-handler! el)) + (du/setv! el k-source-node parent) + (du/setv! el k-bound-event evt))))) + +;; ── Lifecycle ───────────────────────────────────────────────────────────────────── +(defn- connected! [^js el] + (ensure-shadow! el) + (bind-source! el)) + +(defn- disconnected! [^js el] + (detach! el)) + +(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..54f6b629 --- /dev/null +++ b/src/baredom/components/barebuild_invalidate_on/model.cljs @@ -0,0 +1,41 @@ +(ns baredom.components.barebuild-invalidate-on.model) + +;; 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 ─────────────────────────────────────────────────────────────────── +(def event-invalidate "barebuild-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 "barebuild-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/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!]) From c4f38401c217b8c097f25ef7ce41799dfb75e016 Mon Sep 17 00:00:00 2001 From: Alexander van Elsas <58037137+avanelsas@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:11:13 +0200 Subject: [PATCH 02/12] feat(barebuild): complete action + invalidate-on alpha (protocol, shared lifecycle, tests, docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the scaffold: extracts the shared spine, adds tests/demos/docs, and folds in the idiom + Hickey review findings. ALPHA (4.0.0-alpha lane, `alpha` dist-tag) — explicitly unstable; barebuild-bind still deferred. Shared spine (src/baredom/components/barebuild/): - protocol.cljs: single source of truth for the cross-component event-name handshake (data-state / action-state / invalidate) so the independently shipped ESM modules can't drift. Each model re-exports the names it uses. - lifecycle.cljs: shared fetch + state-transition shell for both server-state brokers (abort stale-callback guard, 204/empty-body → success(nil) parse, AbortError suppression, shallow change guard). Read/write sides cannot drift. Elements: - barebuild-action: submit-emitter-by-containment, values-path, JSON fetch, barebuild-action-state {name,state}, submit() method. - barebuild-invalidate-on: parentNode source listener, when-phase/when-name AND-matchers, document-level barebuild-invalidate {src}, invalidate() method. - barebuild-data: extended additively to refetch on barebuild-invalidate (exact URL.pathname match; document listener added/removed with connect). Review-driven refinements: - payload-fn accessor (not a :payload-key string) in the lifecycle change guard: extern-safe, co-located with intent; removed the last gobj coupling. + a "two distinct payloads, same phase+status, re-fire" regression test. - start-fetch! no longer mutates the caller's init literal (Object.assign). - matches? is a pure predicate; the no-matcher diagnostic warns ONCE at connect (not per event). du/get-attr-trimmed centralizes "whitespace = absent" across the brokers (dropped mu from action + invalidate-on). Tests for both new elements + the data invalidate path; demo HTML; per-element docs reconciled to what shipped; README + components.md showcase entries. clj-kondo clean; release lib (Closure Advanced) 0 warnings; full suite 5183 SUCCESS. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 +- demo/barebuild-action.html | 132 +++++++++ demo/barebuild-invalidate-on.html | 83 ++++++ docs/barebuild-action.md | 8 +- docs/barebuild-invalidate-on.md | 10 +- docs/components.md | 8 +- public/index.html | 2 + .../components/barebuild/lifecycle.cljs | 106 +++++++ .../components/barebuild/protocol.cljs | 16 ++ .../barebuild_action/barebuild_action.cljs | 119 +++----- .../components/barebuild_action/model.cljs | 7 +- .../barebuild_data/barebuild_data.cljs | 131 +++------ .../components/barebuild_data/model.cljs | 11 +- .../barebuild_invalidate_on.cljs | 59 ++-- .../barebuild_invalidate_on/model.cljs | 9 +- src/baredom/utils/dom.cljs | 11 + .../barebuild_action_test.cljs | 259 ++++++++++++++++++ .../barebuild_action/model_test.cljs | 61 +++++ .../barebuild_data/barebuild_data_test.cljs | 54 ++++ .../barebuild_invalidate_on_test.cljs | 150 ++++++++++ .../barebuild_invalidate_on/model_test.cljs | 19 ++ 21 files changed, 1037 insertions(+), 222 deletions(-) create mode 100644 demo/barebuild-action.html create mode 100644 demo/barebuild-invalidate-on.html create mode 100644 src/baredom/components/barebuild/lifecycle.cljs create mode 100644 src/baredom/components/barebuild/protocol.cljs create mode 100644 test/baredom/components/barebuild_action/barebuild_action_test.cljs create mode 100644 test/baredom/components/barebuild_action/model_test.cljs create mode 100644 test/baredom/components/barebuild_invalidate_on/barebuild_invalidate_on_test.cljs create mode 100644 test/baredom/components/barebuild_invalidate_on/model_test.cljs diff --git a/README.md b/README.md index f6ffba42..6023c2c1 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ All adapters are auto-generated from the same Custom Elements Manifest, so addin ## Components -**103 UI web components across 11 categories** — from foundational UI controls to morphing animations, organic effects, and scroll-driven storytelling — plus **3 [BareBuild](./barebuild/docs/read-side.md) orchestration elements** (read-side: router / route / data). +**103 UI web components across 11 categories** — from foundational UI controls to morphing animations, organic effects, and scroll-driven storytelling — plus **5 [BareBuild](./barebuild/docs/read-side.md) orchestration elements** (read-side: router / route / data; write-side alpha: action / invalidate-on). | Category | Count | Examples | |----------|------:|----------| @@ -118,7 +118,7 @@ All adapters are auto-generated from the same Custom Elements Manifest, so addin | **Effects** | 12 | Liquid Glass · Confetti · Neural Glow · Metaball Cursor · Organic Shape | | **Scroll** | 5 | Scroll · Scroll Parallax · Scroll Stack · Scroll Story · Scroll Timeline | | **Utility** | 2 | i18n · i18n Provider | -| **Orchestration** | 3 | BareBuild Router · Route · Data — read-side; not adapter-wrapped | +| **Orchestration** | 5 | BareBuild Router · Route · Data — read-side; Action · Invalidate-On — write-side (alpha); not adapter-wrapped | See [**docs/components.md**](./docs/components.md) for the full per-component catalogue with one-line descriptions and links to each component's API documentation. diff --git a/demo/barebuild-action.html b/demo/barebuild-action.html new file mode 100644 index 00000000..6b8ca07c --- /dev/null +++ b/demo/barebuild-action.html @@ -0,0 +1,132 @@ + + + + + barebuild-action demo + + + + + + + + +

<barebuild-action>

+

+ Wraps a submit emitter (any descendant dispatching a cancelable, bubbling event + carrying values at values-path in detail), POSTs the + values as JSON to action, and publishes the HTTP result as + .state + a barebuild-action-state event per phase. + The contained <barebuild-invalidate-on> turns a successful + submit into a barebuild-invalidate; the <barebuild-data> + below self-matches on src and refetches. This demo stubs + fetch over an in-memory list, so the whole write→invalidate→read loop + runs with no backend. +

+ + + +
+ + +
+ + +
+ + + +
    +
    + + + + 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..9f7eeb71 100644 --- a/docs/barebuild-action.md +++ b/docs/barebuild-action.md @@ -56,7 +56,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. @@ -119,7 +119,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..8140af05 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 `pathname + query` equality** (resolved against the page origin) is used by `` to decide if it should refetch — so `/api/items?page=1` and `?page=2` stay distinct (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,7 +141,7 @@ 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 `pathname + query` equality** (each resolved against the page origin): | Invalidate `src` | Matches data `src` | |---|---| @@ -190,12 +190,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/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/src/baredom/components/barebuild/lifecycle.cljs b/src/baredom/components/barebuild/lifecycle.cljs new file mode 100644 index 00000000..91d10971 --- /dev/null +++ b/src/baredom/components/barebuild/lifecycle.cljs @@ -0,0 +1,106 @@ +(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. +;; +;; `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/protocol.cljs b/src/baredom/components/barebuild/protocol.cljs new file mode 100644 index 00000000..568b701d --- /dev/null +++ b/src/baredom/components/barebuild/protocol.cljs @@ -0,0 +1,16 @@ +(ns baredom.components.barebuild.protocol) + +;; Single source of truth for the cross-component event-name HANDSHAKE of the +;; BareBuild server-state family. 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 three +;; 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/event-…`. + +(def event-data-state "barebuild-data-state") +(def event-action-state "barebuild-action-state") +(def event-invalidate "barebuild-invalidate") diff --git a/src/baredom/components/barebuild_action/barebuild_action.cljs b/src/baredom/components/barebuild_action/barebuild_action.cljs index 00d50910..1a2b964c 100644 --- a/src/baredom/components/barebuild_action/barebuild_action.cljs +++ b/src/baredom/components/barebuild_action/barebuild_action.cljs @@ -4,7 +4,7 @@ [goog.object :as gobj] [baredom.utils.component :as component] [baredom.utils.dom :as du] - [baredom.utils.model :as mu] + [baredom.components.barebuild.lifecycle :as lifecycle] [baredom.components.barebuild-action.model :as model])) ;; Effect shell for barebuild-action. Owns no authoritative state — the server @@ -33,87 +33,38 @@ (defn- ensure-shadow! [^js el] (du/ensure-shadow-with-style! el styles k-initialized? false)) -;; ── State transitions (one event per transition) ──────────────────────────────── -(defn- same-state? - "Shallow value-equality for two JS state objects (JS = is identity, so two - distinct-but-equal objects would re-fire). `response` is compared by reference." - [^js a ^js b] - (and (some? a) (some? b) - (= (.-phase a) (.-phase b)) - (identical? (.-response a) (.-response b)) - (= (.-error a) (.-error b)) - (= (.-httpStatus a) (.-httpStatus b)))) - -(defn- set-state! - "Cache `new-state` and dispatch `barebuild-action-state` only when it differs from - the current value (shallow). `name` echoes the action's `name` attribute at the - detail top level so matchers (``) need not traverse state." - [^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-action-state - #js {:name (du/get-attr el model/attr-name) :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 request. A stale - callback (superseded by a newer submit) is a no-op, so an aborted-but-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! - "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] - (-> (.text resp) - (.then (fn on-text [^js text] - (settle! el ctrl - (model/success-state - (when (pos? (.-length (.trim text))) (js/JSON.parse text)) - 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 — intentional teardown, not an error. - (when-not (= "AbortError" (.-name err)) - (settle! el ctrl (model/error-state (.-message err) nil)))) +;; ── 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 + :detail-fn (fn action-detail [^js el ^js state] + #js {:name (du/get-attr el model/attr-name) :state state}) + :success-fn model/success-state + :error-fn model/error-state}) (defn- do-submit! "Abort any in-flight request and POST/PUT/… `values` (JSON) to `action`. A blank - `action` logs and no-ops (never an empty-target request)." + (or whitespace-only) `action` logs and no-ops (never an empty-target request — + an untrimmed whitespace `action` would otherwise resolve to the current page)." [^js el values] - (let [action (du/get-attr el model/attr-action) - method (let [m (du/get-attr el model/attr-method)] - (if (mu/non-empty-string? m) m model/default-method))] - (if-not (mu/non-empty-string? action) + (let [action (du/get-attr-trimmed el model/attr-action) + method (or (du/get-attr-trimmed el model/attr-method) model/default-method)] + (if-not action (js/console.error "barebuild-action: missing `action`; skipping submit") - (let [ctrl (js/AbortController.) - signal (.-signal ctrl)] - (abort-inflight! el) - (du/setv-untraced! el k-abort ctrl) - (set-state! el (model/submitting-state)) - (-> (js/fetch action #js {:method method - :headers #js {"Content-Type" "application/json"} - :body (js/JSON.stringify values) - :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)))))))) + (lifecycle/start-fetch! el action + #js {:method method + :headers #js {"Content-Type" "application/json"} + :body (js/JSON.stringify values)} + (model/submitting-state) + lifecycle-cfg)))) ;; ── Submit-event wiring (containment; no selectors) ────────────────────────────── (defn- parse-path @@ -133,14 +84,13 @@ `detail.values`. Returns nil on a blank path or a miss." [^js detail path-attr] (when (some? detail) - (let [raw (if (mu/non-empty-string? path-attr) path-attr model/default-values-path) - keys (parse-path raw)] + (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] (.preventDefault ev) - (let [values (resolve-values (.-detail ev) (du/get-attr el model/attr-values-path))] + (let [values (resolve-values (.-detail ev) (du/get-attr-trimmed el model/attr-values-path))] (if (some? values) (do-submit! el values) (js/console.error "barebuild-action: no values at values-path; skipping (no empty-body request)")))) @@ -154,7 +104,8 @@ (defn- detach-listener! [^js el] (let [^js h (du/getv el k-submit-handler) bound (du/getv el k-bound-event)] - (when (and h (mu/non-empty-string? bound)) + ;; `bound` is nil or a non-blank event name (set from get-attr-trimmed). + (when (and h bound) (.removeEventListener el bound h) (du/setv! el k-bound-event nil)))) @@ -163,8 +114,8 @@ `submit-event` logs an error and leaves the action inert (never fetches)." [^js el] (detach-listener! el) - (let [evt (du/get-attr el model/attr-submit-event)] - (if-not (mu/non-empty-string? evt) + (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") (do (.addEventListener el evt (ensure-handler! el)) (du/setv! el k-bound-event evt))))) @@ -177,7 +128,7 @@ (defn- disconnected! [^js el] (detach-listener! el) - (abort-inflight! el) + (lifecycle/abort-inflight! el k-abort) (du/setv! el k-state default-idle)) (defn- attribute-changed! [^js el name _old-val _new-val] diff --git a/src/baredom/components/barebuild_action/model.cljs b/src/baredom/components/barebuild_action/model.cljs index 3c8d71ec..716d59e3 100644 --- a/src/baredom/components/barebuild_action/model.cljs +++ b/src/baredom/components/barebuild_action/model.cljs @@ -1,5 +1,6 @@ (ns baredom.components.barebuild-action.model - (:require [goog.object :as gobj])) + (: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` @@ -29,7 +30,9 @@ #js [attr-name attr-action attr-method attr-submit-event attr-values-path]) ;; ── Events ─────────────────────────────────────────────────────────────────── -(def event-action-state "barebuild-action-state") ; emitted on every phase transition +;; 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") diff --git a/src/baredom/components/barebuild_data/barebuild_data.cljs b/src/baredom/components/barebuild_data/barebuild_data.cljs index af9aff7b..904243b4 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 — @@ -42,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; @@ -115,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] @@ -139,20 +80,24 @@ (du/setv! el k-refresh-handler h) (.addEventListener el model/event-data-refresh h))) -;; ── Invalidate listener (on document; matched by URL.pathname) ─────────────────── -(defn- url-pathname - "The pathname of `s` resolved against the page origin, or nil for a blank/invalid - src. Invalidation matches on pathname so a relative `src` and an absolute one to - the same path are equal." +;; ── Invalidate listener (on document; matched by URL pathname + query) ─────────── +(defn- match-key + "The match key of `s` resolved against the page origin: `pathname + search`, or + nil for a blank/invalid src. Resolving against the origin makes a relative `src` + and an absolute one to the same path equal; 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 (.-pathname (js/URL. s (.. js/location -origin))) (catch :default _ nil)))) + (try (let [^js u (js/URL. s (.. js/location -origin))] + (str (.-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 - ;; pathname equality), re-read. A blank/mismatched src is ignored. - (let [own (url-pathname (du/get-attr el model/attr-src))] - (when (and own (= own (url-pathname (.. e -detail -src)))) + ;; pathname+query equality), re-read. A blank/mismatched src is ignored. + (let [own (match-key (du/get-attr el model/attr-src))] + (when (and own (= own (match-key (.. e -detail -src)))) (fetch! el)))) (defn- ensure-invalidate-handler! [^js el] @@ -179,7 +124,7 @@ (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) + (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)) diff --git a/src/baredom/components/barebuild_data/model.cljs b/src/baredom/components/barebuild_data/model.cljs index ac25cbdc..fd243467 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,9 +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 on self; user-driven manual refetch -(def event-invalidate "barebuild-invalidate") ; listened for at document; refetch when detail.src matches our src +;; 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` diff --git a/src/baredom/components/barebuild_invalidate_on/barebuild_invalidate_on.cljs b/src/baredom/components/barebuild_invalidate_on/barebuild_invalidate_on.cljs index 5dc4d653..84030e97 100644 --- a/src/baredom/components/barebuild_invalidate_on/barebuild_invalidate_on.cljs +++ b/src/baredom/components/barebuild_invalidate_on/barebuild_invalidate_on.cljs @@ -2,7 +2,6 @@ (:require [baredom.utils.component :as component] [baredom.utils.dom :as du] - [baredom.utils.model :as mu] [baredom.components.barebuild-invalidate-on.model :as model])) ;; Effect shell for barebuild-invalidate-on. Holds only its attributes. Listens to @@ -21,26 +20,41 @@ (defn- ensure-shadow! [^js el] (du/ensure-shadow-with-style! el styles k-initialized? false)) -;; ── Matching ──────────────────────────────────────────────────────────────────── +;; ── Matching (pure) ────────────────────────────────────────────────────────────── (defn- matches? - "True when the configured matchers all fire on `ev`. when-phase compares - detail.state.phase; when-name compares detail.name. At least one matcher must be - set (both unset → log + no-match); set matchers are AND-composed." + "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 el model/attr-when-phase) - when-name (du/get-attr el model/attr-when-name) - phase-set? (mu/non-empty-string? when-phase) - name-set? (mu/non-empty-string? when-name)] - (if-not (or phase-set? name-set?) - (do (js/console.error "barebuild-invalidate-on: needs at least one of when-phase / when-name; no-op") - false) - (let [^js detail (.-detail ev) - phase-ok (or (not phase-set?) (= when-phase (some-> detail .-state .-phase))) - name-ok (or (not name-set?) (= when-name (some-> detail .-name)))] - (and phase-ok name-ok))))) - -(defn- do-invalidate! [^js el] - (du/dispatch! el model/event-invalidate #js {:src (du/get-attr el model/attr-src)})) + (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) @@ -57,7 +71,8 @@ (let [^js h (du/getv el k-handler) ^js node (du/getv el k-source-node) bound (du/getv el k-bound-event)] - (when (and h node (mu/non-empty-string? bound)) + ;; `bound` is nil or a non-blank event name (set from get-attr-trimmed-or-default). + (when (and h node bound) (.removeEventListener node bound h) (du/setv! el k-source-node nil) (du/setv! el k-bound-event nil)))) @@ -68,8 +83,7 @@ [^js el] (detach! el) (let [^js parent (.-parentNode el) - evt (let [e (du/get-attr el model/attr-event)] - (if (mu/non-empty-string? e) e model/default-source-event))] + 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") (do (.addEventListener parent evt (ensure-handler! el)) @@ -79,6 +93,7 @@ ;; ── Lifecycle ───────────────────────────────────────────────────────────────────── (defn- connected! [^js el] (ensure-shadow! el) + (warn-no-matcher! el) (bind-source! el)) (defn- disconnected! [^js el] diff --git a/src/baredom/components/barebuild_invalidate_on/model.cljs b/src/baredom/components/barebuild_invalidate_on/model.cljs index 54f6b629..4bc6e138 100644 --- a/src/baredom/components/barebuild_invalidate_on/model.cljs +++ b/src/baredom/components/barebuild_invalidate_on/model.cljs @@ -1,4 +1,5 @@ -(ns baredom.components.barebuild-invalidate-on.model) +(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 @@ -22,11 +23,13 @@ #js [attr-event attr-when-phase attr-when-name attr-src]) ;; ── Events ─────────────────────────────────────────────────────────────────── -(def event-invalidate "barebuild-invalidate") ; dispatched on a full matcher match +;; 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 "barebuild-action-state") +(def default-source-event protocol/event-action-state) (def property-api {:event {:type 'string :reflects-attribute attr-event :default ""} 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..0b666ee1 --- /dev/null +++ b/test/baredom/components/barebuild_action/barebuild_action_test.cljs @@ -0,0 +1,259 @@ +(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))))))) 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..495b75ce 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,32 @@ (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))))))))))) + ;; ── 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))))) From 9295f17fd8bc320a2584516afd1dd29853cc9e4c Mon Sep 17 00:00:00 2001 From: Alexander van Elsas <58037137+avanelsas@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:10:12 +0200 Subject: [PATCH 03/12] feat(barebuild): port demo CREATE onto barebuild-action; fix 2 element bugs the port found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "would I use it?" graduation test: port the demo's hand-wired write side onto the alpha declarative elements. Started with CREATE (the flagship, modal-based). Two REAL barebuild-action bugs the port surfaced (both fixed): - Missing : ensure-shadow! passed slot? = false (copied from the childless barebuild-data), so the wrapped form stayed in the light DOM and FUNCTIONED but never RENDERED (0×0). A content-wrapper needs a slot. Fixed: slot? = true. - :host{display:none} → display:block: a wrapper must not hide its content (display:contents collapses it as a flex-item; block is layout-neutral). Root cause of both: a containment-wrapper can't be cloned from a leaf broker. CREATE port (works; full Playwright e2e 11/11, incl. a new write-path test): - index.html: wraps the MODAL (not the form, which would displace it as x-modal's slotted child and break ::slotted layout); refetches /api/tasks on success. core.cljs registers the two elements. - write_side: on-create-submit! (fetch+refresh) → on-create-state!, which only reacts to barebuild-action-state for the side-effects the elements DON'T cover (close modal, reset, toast). without-blanks dropped — exposing the payload-cleanliness gap (the action encodes values as-is, so status comes through ""). Evaluation recorded in write-side-design-notes.md: declarative fetch+invalidate is real and less code for the canonical flow, but every flow still needs imperative side-effects, and update/settings (dynamic URLs, number coercion) + the two deletes (no-values / dynamic-row triggers) are deferred/poor-fit. UPDATE/SETTINGS/DELETE remain hand-wired pending a decision on the full port. Co-Authored-By: Claude Opus 4.8 (1M context) --- barebuild/demo-app/e2e/write-paths.spec.ts | 31 +++++++++ barebuild/demo-app/public/index.html | 40 ++++++----- barebuild/demo-app/src/demo_app/core.cljs | 4 ++ barebuild/demo-app/src/demo_app/wiring.cljs | 1 + .../demo-app/src/demo_app/write_side.cljs | 60 +++++++---------- barebuild/docs/write-side-design-notes.md | 67 +++++++++++++++++++ .../barebuild_action/barebuild_action.cljs | 14 ++-- 7 files changed, 162 insertions(+), 55 deletions(-) create mode 100644 barebuild/demo-app/e2e/write-paths.spec.ts diff --git a/barebuild/demo-app/e2e/write-paths.spec.ts b/barebuild/demo-app/e2e/write-paths.spec.ts new file mode 100644 index 00000000..592843fc --- /dev/null +++ b/barebuild/demo-app/e2e/write-paths.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; + +// Write-path E2E for the ALPHA declarative write side (barebuild-action + +// barebuild-invalidate-on). Drives the real stack and mutates server state, so +// each assertion compares against the live server rather than hardcoded counts. + +test('create: barebuild-action POSTs and invalidate-on refetches the board', async ({ page, request }) => { + await page.goto('/tasks'); + await page.waitForSelector('#tasks-table x-table-row'); + const before = (await (await request.get('/api/tasks')).json()).length; + + // Open the modal (in-app: x-button → press → modal.show; call show() directly for a + // deterministic headless run), then fill + submit. + await page.evaluate(() => (document.querySelector('#new-task-modal') as { show(): void }).show()); + const title = page.locator('#new-task-form x-form-field[name="title"] input'); + await title.waitFor({ state: 'visible' }); + await title.fill('E2E declarative task'); + await page.click('#new-task-form x-button[type="submit"]'); + + // No hand-written fetch in write_side: POSTed and its child + // refetched /api/tasks, so the new row appears. + await expect(page.locator('#tasks-table')).toContainText('E2E declarative task'); + + const after = await (await request.get('/api/tasks')).json(); + const created = after.find((t: { title: string }) => t.title === 'E2E declarative task'); + expect(created).toBeTruthy(); + expect(after.length).toBe(before + 1); + // NOTE: created.status comes through "" — the action JSON-encodes the form values + // as-is and cannot blank-strip (the hand-wired version used without-blanks). A + // documented payload-cleanliness gap; see write-side-design-notes.md. +}); diff --git a/barebuild/demo-app/public/index.html b/barebuild/demo-app/public/index.html index b47f0e28..442c67ac 100644 --- a/barebuild/demo-app/public/index.html +++ b/barebuild/demo-app/public/index.html @@ -77,21 +77,31 @@

    Board

    - - - New task - - - - - - - -
    - Create -
    -
    -
    + + + + New task + + + + + + + +
    + Create +
    +
    +
    + +
    diff --git a/barebuild/demo-app/src/demo_app/core.cljs b/barebuild/demo-app/src/demo_app/core.cljs index 6ba4f7c3..611fbb91 100644 --- a/barebuild/demo-app/src/demo_app/core.cljs +++ b/barebuild/demo-app/src/demo_app/core.cljs @@ -16,6 +16,9 @@ ["@vanelsas/baredom/barebuild-router" :as barebuild-router] ["@vanelsas/baredom/barebuild-route" :as barebuild-route] ["@vanelsas/baredom/barebuild-data" :as barebuild-data] + ;; Write-side ALPHA (4.0.0-alpha) — declarative submit→fetch + invalidation + ["@vanelsas/baredom/barebuild-action" :as barebuild-action] + ["@vanelsas/baredom/barebuild-invalidate-on" :as barebuild-invalidate-on] ;; UI components (dogfood) ["@vanelsas/baredom/x-navbar" :as x-navbar] ["@vanelsas/baredom/x-table" :as x-table] @@ -46,6 +49,7 @@ the listeners BEFORE calling this, so a deep-load still delivers that push." [] (doseq [^js m [barebuild-router barebuild-route barebuild-data + barebuild-action barebuild-invalidate-on x-navbar x-table x-table-row x-table-cell x-stat x-badge x-button x-search-field x-select x-modal x-form x-form-field x-text-area x-date-picker x-card x-alert x-skeleton diff --git a/barebuild/demo-app/src/demo_app/wiring.cljs b/barebuild/demo-app/src/demo_app/wiring.cljs index 996a3442..363e8642 100644 --- a/barebuild/demo-app/src/demo_app/wiring.cljs +++ b/barebuild/demo-app/src/demo_app/wiring.cljs @@ -13,6 +13,7 @@ (def ev-data-state "barebuild-data-state") (def ev-data-refresh "barebuild-data-refresh") ; dispatched AT a to refetch its src (def ev-navigate "barebuild-navigate") ; dispatched AT the router to SPA-navigate without a click +(def ev-action-state "barebuild-action-state") ; emitted by a on each phase transition ;; Component events the demo reacts to. (def ev-search-input "x-search-field-input") (def ev-select-change "select-change") diff --git a/barebuild/demo-app/src/demo_app/write_side.cljs b/barebuild/demo-app/src/demo_app/write_side.cljs index 27777e05..42f6143d 100644 --- a/barebuild/demo-app/src/demo_app/write_side.cljs +++ b/barebuild/demo-app/src/demo_app/write_side.cljs @@ -2,12 +2,16 @@ "Write-side wiring for the demo — the live create / update / delete / settings handlers (create modal, detail edit, delete confirm, board-row delete, settings). - BareBuild V1 ships only the READ side (router / route / data); it ships NO - write-side elements ( / / - are deferred). So this demo wires its writes BY HAND with addEventListener + fetch — - and that hand-wiring was the Phase-4 telemetry that informed the write-side design, - now recorded in barebuild/docs/write-side-design-notes.md. The five questions it - answered, for reference: + ★ ALPHA-BRANCH HYBRID ★ This branch ships the experimental write-side elements, and + CREATE is ported onto them: (POST + submit→fetch) wraps the new-task + modal in index.html and refetches /api/tasks on success — so + on-create-state! below only reacts to barebuild-action-state for the UI side-effects the + elements do NOT cover (close modal, reset, toast). UPDATE / DELETE / SETTINGS remain + hand-wired (addEventListener + fetch) — see the port evaluation in + barebuild/docs/write-side-design-notes.md for why (dynamic URLs, no-values triggers). + + The original (V1) hand-wiring was the Phase-4 telemetry that informed the design. The + five questions it answered, for reference: 1. submit → fetch: hook x-form-submit; POST/PUT/DELETE via api/request. Payload is event.detail.values, blank-stripped on CREATE only (without-blanks). @@ -60,20 +64,6 @@ #js {:bubbles true :composed true :detail #js {:path path}})))) -(defn- without-blanks - "Drop name→value pairs whose value is a blank string. CREATE-ONLY. x-form reports - every named control, defaulting an unset one to \"\", and on a POST a present-but-blank - key shadows the server's default (a blank `status` would override {:status \"todo\"}); - omitting blanks lets the defaults apply. Do NOT use on a PUT/merge endpoint — there a - blank is a deliberate \"clear this field\", and dropping it makes the clear silently fail." - [^js values] - (let [out #js {}] - (doseq [k (js/Object.keys values)] - (let [v (aget values k)] - (when-not (and (string? v) (= "" (.trim v))) - (aset out k v)))) - out)) - (defn- with-number "Return a shallow copy of `values` with key `k` parsed to an integer (left as-is if unparseable). x-form reports a number control's value as a string; on a PUT/merge that @@ -103,20 +93,17 @@ (js/console.error err) (notify! msg "error"))) -;; ── 1. CREATE — New-task modal form submit ──────────────────────────────────── -(defn- on-create-submit! [^js e] - (.preventDefault e) - ;; Capture the form NOW: in the async .then, e.currentTarget is null (the event has - ;; finished dispatching), so a reset must hold the handle from sync time. - (let [^js form (.-currentTarget e)] - (-> (api/request "POST" api-tasks (without-blanks (.. e -detail -values))) - (.then (fn created [_created] - (refresh-data! w/id-tasks-data) ;; board re-reads /api/tasks → re-renders - (when-let [^js m (.querySelector js/document w/id-new-task-modal)] - (.hide m)) ;; x-modal :hide closes the dialog - (.reset form) ;; clear inputs so a reopen starts blank - (notify! "Task created" "success"))) - (.catch (on-failure "Create failed"))))) +;; ── 1. CREATE — DECLARATIVE (barebuild-action + invalidate-on) ───────────────── +;; The elements do submit→POST→refetch /api/tasks (see #create-action in index.html). +;; write_side only reacts to the published barebuild-action-state for the UI side- +;; effects the elements do NOT cover: close the modal, reset the form, toast. +(defn- on-create-state! [^js e] + (case (.. e -detail -state -phase) + "success" (do (when-let [^js m (.querySelector js/document w/id-new-task-modal)] (.hide m)) + (when-let [^js f (.querySelector js/document w/id-new-task-form)] (.reset f)) + (notify! "Task created" "success")) + "error" (notify! "Create failed" "error") + nil)) ;; ── 2. UPDATE — detail inline edit form submit ──────────────────────────────── (defn- on-edit-submit! [^js e] @@ -171,12 +158,13 @@ read-side wiring (before component registration, so the listeners are live when the elements upgrade). Each handler performs its write, then re-reads the server." [] - (let [^js new-form (.querySelector js/document w/id-new-task-form) + (let [^js create-action (.querySelector js/document "#create-action") ^js edit-form (.querySelector js/document w/id-edit-task-form) ^js confirm (.querySelector js/document w/id-delete-confirm) ^js table (.querySelector js/document w/id-tasks-table) ^js settings-form (.querySelector js/document w/id-settings-form)] - (.addEventListener new-form w/ev-form-submit on-create-submit!) + ;; CREATE is declarative — we only listen to the action's published state. + (.addEventListener create-action w/ev-action-state on-create-state!) (.addEventListener edit-form w/ev-form-submit on-edit-submit!) (.addEventListener confirm w/ev-confirm on-delete-confirm!) ;; Board row Delete buttons are delegated through the table (press bubbles). diff --git a/barebuild/docs/write-side-design-notes.md b/barebuild/docs/write-side-design-notes.md index 0c9b2c0d..685b8bc9 100644 --- a/barebuild/docs/write-side-design-notes.md +++ b/barebuild/docs/write-side-design-notes.md @@ -254,6 +254,73 @@ resists. Add `name` *with* bind, when something consumes it. --- +## Port evaluation: hand-wired → declarative (the "would I use it?" test) + +**Date 2026-06-03.** The alpha elements (`` + ``) +were built (branch `feat/barebuild-write-side`) and the demo's CREATE flow was ported off +the hand-wired `write_side.cljs` onto them, as the solo-author graduation gate. Verdict so +far: **the declarative fetch+invalidate works and is genuinely less code for the canonical +flow — but the port found two real element bugs and confirmed the elements are not a clean +drop-in.** Recorded against the gaps the analysis predicted. + +### Two element bugs the port found (both fixed in the alpha) +1. **Missing `` (critical).** `barebuild-action` attached its shadow root via + `ensure-shadow-with-style! … slot? = false` (copied from the childless `barebuild-data`). + An element that *wraps* content needs a slot — without one the wrapped form stayed in the + light DOM and **functioned (events, `.value`, submit all worked) but never rendered** + (0×0). This is the trap of modelling a content-wrapper on a leaf broker. Fixed: `slot? = true`. +2. **`:host{display:none}`.** Also copied from the leaf brokers; it would hide the wrapped + content. A wrapper needs a visible display — `display:block`. (`display:contents` is worse + here: it collapses the wrapped content when the action is a flex-item.) + +Both bugs share a root cause worth stating: **a containment-wrapper element is a different +animal from a non-visual leaf broker, and cannot be cloned from one.** `barebuild-data` / +`barebuild-invalidate-on` are leaves (no slot, display:none, correct); `barebuild-action` +wraps visible content (slot, display:block). + +### Slot-displacement (a structural gotcha, not a bug) +Wrapping the form *directly* (``) inside an +`` makes the action — not the form — the modal's slotted child, which can break a +host that styles its slotted children by selector. The fix is structural: **wrap the modal, +not the form** (`…`). The composed `x-form-submit` still +bubbles out of the modal to the action. A real "mind where you put the wrapper" cost that +hand-wiring (a listener, no wrapper) never imposes. + +### What the elements absorbed vs. what stayed imperative (CREATE) +- **Absorbed declaratively:** submit→POST `/api/tasks`, and refetch the board on success + (``). The hand-wired + `on-create-submit!` (fetch + `refresh-data!`) is gone. +- **Stayed imperative** (a `barebuild-action-state` listener in `write_side`): close the + modal, reset the form, success/error toast. The elements cover the *data* round-trip; every + *UI* side-effect is still hand-wired. +- **Payload-cleanliness gap (unresolved):** the action JSON-encodes `event.detail.values` + **as-is**. The hand-wired create used `without-blanks` so a blank `status` wouldn't shadow + the server default; the action can't, so the ported create writes `status: ""`. There is no + values-transform hook. (Settings' `with-number` coercion is the same class of gap.) + +### Flows NOT ported, with the reason (the analysis held) +- **Update / settings** (form flows): portable like create, but `action`/`src` are **dynamic** + (`/api/tasks/:id`) and must be set imperatively per route — and settings hits the number-coercion + gap. +- **Delete (detail confirm)** and **delete (board row):** **poor fit.** The triggers carry **no + values** (a confirm event; a delegated row button), so the action's values-required submit + path skips; and the row case is dynamically-rendered per-id. Event delegation (hand-wired) is + the right tool; the containment-action is not. + +### Provisional verdict +The declarative path is **real and worth it for the canonical "form POST → refetch a list"** +— once the element bugs above are fixed. But across a real write surface it is **hybrid**: +declarative for fetch+invalidate, imperative for side-effects (toast/modal/reset/navigate), +dynamic URLs, payload cleaning, and non-form triggers. Whether the markup wrapper + the +`barebuild-action-state` listener is *nicer to write* than the hand-wired `fetch`-then-refresh +is a genuine toss-up for anything past the canonical flow. The strongest concrete asks the port +surfaced, if these graduate: (a) a **values-transform hook** on the action (blanks/coercion), +(b) first-class **dynamic `action`/`src`** ergonomics, (c) a **no-values / imperative trigger** +path for deletes. Without those, the elements cover the easy half and leave the awkward half +hand-wired. + +--- + ## V1.1 recommendation _Provisional — N=1, biased. Do not act until the gate is met._ diff --git a/src/baredom/components/barebuild_action/barebuild_action.cljs b/src/baredom/components/barebuild_action/barebuild_action.cljs index 1a2b964c..8188979c 100644 --- a/src/baredom/components/barebuild_action/barebuild_action.cljs +++ b/src/baredom/components/barebuild_action/barebuild_action.cljs @@ -27,11 +27,17 @@ ;; Frozen (every constructor freezes), so this singleton cannot be mutated. (def ^:private default-idle (model/idle-state)) -(def ^:private styles ":host{display:none}") - -;; ── Shadow DOM (non-visual; renders nothing) ──────────────────────────────────── +;; 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? false)) + (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 From 43ab1aa15cd81564aabc2568231c23dea5107d3e Mon Sep 17 00:00:00 2001 From: Alexander van Elsas <58037137+avanelsas@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:40:52 +0200 Subject: [PATCH 04/12] =?UTF-8?q?feat(barebuild):=20finish=20the=20write-s?= =?UTF-8?q?ide=20port=20=E2=80=94=20update/settings=20declarative,=20delet?= =?UTF-8?q?es=20via=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the demo port onto the alpha elements (Playwright e2e 14/14). UPDATE + SETTINGS — now declarative: - index.html: wraps the edit form (PUT) and the settings form (PUT), each with a child . The edit action's dynamic /api/tasks/:id `action`/`src` is set in detail/on-route-change (the one imperative line a param route needs). write_side replaces on-edit-submit!/on-settings-save! with a shared toasting-state-handler (the only uncovered side-effect is the toast). DELETE — hand-wired trigger, PROTOCOL coordination (the recommended shape): - The action can't drive deletes (a confirm dialogue and N per-id row buttons carry no form values; the row case is a dynamic list → event delegation). So the trigger stays hand-wired, but coordination goes through the SAME document protocols the declarative flows use: detail-delete dispatches barebuild-navigate; row-delete dispatches barebuild-invalidate {src} (new dispatch-invalidate! helper) — the identical event emits. Board refetches the same way regardless of trigger. The reframe this produced: barebuild-invalidate / barebuild-navigate are PUBLIC PROTOCOLS, the elements are sugar over them. So "DELETE doesn't fit barebuild-action" dissolves — its trigger is correctly hand-wired, its coordination uniformly declarative. Docs: write-side-design-notes.md evaluation rewritten for the full port (the protocol reframe + residual gaps: payload cleanliness — status "" / page-size string, slotted-wrapper placement). docs/barebuild-action.md gains the rendering/placement corrections (slot, display:block, wrap-the-host) and the protocol framing. Removed now-dead write_side code (with-number, refresh-data!, api-settings, on-edit-submit!, on-settings-save!). clj-kondo clean; demo compiles 0 warnings; full e2e 14/14. Co-Authored-By: Claude Opus 4.8 (1M context) --- barebuild/demo-app/e2e/write-paths.spec.ts | 36 +++++ barebuild/demo-app/public/index.html | 67 +++++---- barebuild/demo-app/src/demo_app/detail.cljs | 10 +- barebuild/demo-app/src/demo_app/wiring.cljs | 1 + .../demo-app/src/demo_app/write_side.cljs | 139 +++++++++--------- barebuild/docs/write-side-design-notes.md | 64 +++++--- docs/barebuild-action.md | 29 ++++ 7 files changed, 226 insertions(+), 120 deletions(-) diff --git a/barebuild/demo-app/e2e/write-paths.spec.ts b/barebuild/demo-app/e2e/write-paths.spec.ts index 592843fc..e2aa4958 100644 --- a/barebuild/demo-app/e2e/write-paths.spec.ts +++ b/barebuild/demo-app/e2e/write-paths.spec.ts @@ -29,3 +29,39 @@ test('create: barebuild-action POSTs and invalidate-on refetches the board', asy // as-is and cannot blank-strip (the hand-wired version used without-blanks). A // documented payload-cleanliness gap; see write-side-design-notes.md. }); + +test('update: barebuild-action PUTs the edit form (dynamic /api/tasks/:id)', async ({ page, request }) => { + await page.goto('/tasks/2'); + await page.waitForSelector('#detail-title'); + // The edit action's URL was set to /api/tasks/2 by detail/on-route-change. + await page.fill('#edit-task-form x-form-field[name="assignee"] input', 'Ported Dev'); + await page.click('#edit-task-form x-button[type="submit"]'); + await expect + .poll(async () => (await (await request.get('/api/tasks/2')).json()).assignee) + .toBe('Ported Dev'); +}); + +test('settings: barebuild-action PUTs and invalidate-on refetches', async ({ page, request }) => { + await page.goto('/settings'); + await expect + .poll(() => page.evaluate(() => (document.querySelector('#settings-form [name="theme"]') as { value?: string })?.value)) + .toBe('system'); + await page.evaluate(() => { (document.querySelector('#settings-form [name="theme"]') as { value: string }).value = 'dark'; }); + await page.click('#settings-form x-button[type="submit"]'); + await expect + .poll(async () => (await (await request.get('/api/settings')).json()).theme) + .toBe('dark'); +}); + +test('delete (row): hand-wired DELETE + barebuild-invalidate refetches the board', async ({ page, request }) => { + await page.goto('/tasks'); + await page.waitForSelector('#tasks-table x-table-row'); + const before = await (await request.get('/api/tasks')).json(); + const victim = before[before.length - 1].id; + await page.click(`#tasks-table x-button[data-delete-id="${victim}"]`); + // The delegated handler DELETEs, then dispatches barebuild-invalidate {/api/tasks}; + // the board self-matches and refetches — the same protocol the declarative flows use. + await expect + .poll(async () => (await (await request.get('/api/tasks')).json()).length) + .toBe(before.length - 1); +}); diff --git a/barebuild/demo-app/public/index.html b/barebuild/demo-app/public/index.html index 442c67ac..4aab4578 100644 --- a/barebuild/demo-app/public/index.html +++ b/barebuild/demo-app/public/index.html @@ -116,19 +116,26 @@

    Board

    Task

    - - - - - - - - -
    - Save - Delete -
    -
    + + + + + + + + + +
    + Save + Delete +
    +
    + +
    Task

    Settings

    - - - - - - - - - -
    - Save settings -
    -
    + + + + + + + + + + + +
    + Save settings +
    +
    + +
    diff --git a/barebuild/demo-app/src/demo_app/detail.cljs b/barebuild/demo-app/src/demo_app/detail.cljs index 87d67323..80546e7a 100644 --- a/barebuild/demo-app/src/demo_app/detail.cljs +++ b/barebuild/demo-app/src/demo_app/detail.cljs @@ -42,8 +42,14 @@ [^js e] (let [id (gobj/get (.. e -detail -params) "id")] (when (and id (= (str w/path-tasks "/" id) (.. e -detail -path))) - (let [^js route (.-currentTarget e)] - (set! (.-src (.querySelector route w/id-detail-data)) (str "/api/tasks/" id)))))) + (let [^js route (.-currentTarget e) + endpoint (str "/api/tasks/" id)] + (set! (.-src (.querySelector route w/id-detail-data)) endpoint) + ;; Write-side ALPHA: point the edit action (PUT) + its invalidate-on at this task's + ;; endpoint. The dynamic /api/tasks/:id URL is the one imperative bit the declarative + ;; update needs — the action's `action` attribute can't be a static literal here. + (set! (.-action (.querySelector route "#edit-action")) endpoint) + (set! (.-src (.querySelector route "#edit-invalidate")) endpoint))))) (defn- on-data-state ;; Renders straight from e.detail.state.data — unlike board, detail has no diff --git a/barebuild/demo-app/src/demo_app/wiring.cljs b/barebuild/demo-app/src/demo_app/wiring.cljs index 363e8642..ad23eb88 100644 --- a/barebuild/demo-app/src/demo_app/wiring.cljs +++ b/barebuild/demo-app/src/demo_app/wiring.cljs @@ -14,6 +14,7 @@ (def ev-data-refresh "barebuild-data-refresh") ; dispatched AT a to refetch its src (def ev-navigate "barebuild-navigate") ; dispatched AT the router to SPA-navigate without a click (def ev-action-state "barebuild-action-state") ; emitted by a on each phase transition +(def ev-invalidate "barebuild-invalidate") ; document-level protocol: a matching refetches ;; Component events the demo reacts to. (def ev-search-input "x-search-field-input") (def ev-select-change "select-change") diff --git a/barebuild/demo-app/src/demo_app/write_side.cljs b/barebuild/demo-app/src/demo_app/write_side.cljs index 42f6143d..2e736315 100644 --- a/barebuild/demo-app/src/demo_app/write_side.cljs +++ b/barebuild/demo-app/src/demo_app/write_side.cljs @@ -2,13 +2,22 @@ "Write-side wiring for the demo — the live create / update / delete / settings handlers (create modal, detail edit, delete confirm, board-row delete, settings). - ★ ALPHA-BRANCH HYBRID ★ This branch ships the experimental write-side elements, and - CREATE is ported onto them: (POST + submit→fetch) wraps the new-task - modal in index.html and refetches /api/tasks on success — so - on-create-state! below only reacts to barebuild-action-state for the UI side-effects the - elements do NOT cover (close modal, reset, toast). UPDATE / DELETE / SETTINGS remain - hand-wired (addEventListener + fetch) — see the port evaluation in - barebuild/docs/write-side-design-notes.md for why (dynamic URLs, no-values triggers). + ★ ALPHA-BRANCH HYBRID ★ This branch ships the experimental write-side elements, and the + three FORM flows are ported onto them: + • CREATE — wraps the new-task modal (POST /api/tasks). + • UPDATE — wraps the edit form (PUT /api/tasks/:id; the dynamic + URL is set in detail/on-route-change). + • SETTINGS — wraps the settings form (PUT /api/settings). + Each has a child that refetches on success. So for these, + write_side only reacts to barebuild-action-state for the UI side-effects the elements + do NOT cover (close modal, reset, toast). + + The two DELETES stay hand-wired — the action can't drive them (a confirm dialogue and N + per-id row buttons carry no form values) — but they COORDINATE through the same document + protocols the declarative flows use: detail-delete dispatches `barebuild-navigate`, + row-delete dispatches `barebuild-invalidate {src}`. So every write invalidates-or-navigates + the same way; only the trigger differs. See the port evaluation in + barebuild/docs/write-side-design-notes.md. The original (V1) hand-wiring was the Phase-4 telemetry that informed the design. The five questions it answered, for reference: @@ -28,8 +37,7 @@ (:require [demo-app.wiring :as w] [demo-app.api :as api])) -(def ^:private api-tasks "/api/tasks") -(def ^:private api-settings "/api/settings") +(def ^:private api-tasks "/api/tasks") (defn- api-task "The single-task endpoint for `id` — used by the board row delete, whose id comes @@ -47,11 +55,15 @@ ^js broker (.querySelector route w/id-detail-data)] (.-src broker))) -(defn- refresh-data! - "Ask a broker (by id selector) to re-run its current src fetch." - [selector] - (when-let [^js el (.querySelector js/document selector)] - (.dispatchEvent el (js/CustomEvent. w/ev-data-refresh)))) +(defn- dispatch-invalidate! + "Dispatch the document-level `barebuild-invalidate {src}` protocol — the SAME signal + `` emits for the declarative flows. Any + whose src matches refetches. Lets a hand-wired write (a delete the action can't drive) + invalidate exactly like the declarative ones, so the coordination stays uniform." + [src] + (.dispatchEvent js/document + (js/CustomEvent. w/ev-invalidate + #js {:bubbles true :composed true :detail #js {:src src}}))) (defn- navigate! "Programmatic SPA navigation: dispatch the router's `barebuild-navigate` AT the @@ -64,16 +76,6 @@ #js {:bubbles true :composed true :detail #js {:path path}})))) -(defn- with-number - "Return a shallow copy of `values` with key `k` parsed to an integer (left as-is if - unparseable). x-form reports a number control's value as a string; on a PUT/merge that - would otherwise flip the server field's type (e.g. page-size 25 → \"25\") on first save." - [^js values k] - (let [out (js/Object.assign #js {} values) - n (js/parseInt (aget out k) 10)] - (when-not (js/isNaN n) (aset out k n)) - out)) - (defn- toaster [] (.querySelector js/document "#toaster")) (defn- notify! @@ -105,68 +107,61 @@ "error" (notify! "Create failed" "error") nil)) -;; ── 2. UPDATE — detail inline edit form submit ──────────────────────────────── -(defn- on-edit-submit! [^js e] - (.preventDefault e) - ;; PUT the edited fields AS-IS (no blank-stripping): this is a merge endpoint, so a - ;; blank field is a deliberate "clear it" — dropping blanks would make that clear - ;; silently fail. Then refresh the detail broker so the card + form re-render from the - ;; server. The board isn't mounted on this route; it re-reads on its next activation. - (-> (api/request "PUT" (detail-endpoint e) (.. e -detail -values)) - (.then (fn updated [_updated] - (refresh-data! w/id-detail-data) ;; detail re-reads /api/tasks/:id → re-renders - (notify! "Task updated" "success"))) - (.catch (on-failure "Update failed")))) - -;; ── 3. DELETE (detail) — confirm dialogue confirmed ─────────────────────────── +;; ── 2 + 5. UPDATE / SETTINGS — DECLARATIVE (barebuild-action + invalidate-on) ── +;; The elements PUT and refetch (#edit-action / #settings-action in index.html; the edit +;; action's dynamic /api/tasks/:id URL is set in detail/on-route-change). The only side- +;; effect they don't cover is the result toast — so write_side just reacts to the state. +(defn- toasting-state-handler + "A barebuild-action-state listener that toasts `success-msg` / `error-msg` by phase. + Shared by update + settings (their only uncovered side-effect is the toast)." + [success-msg error-msg] + (fn on-state [^js e] + (case (.. e -detail -state -phase) + "success" (notify! success-msg "success") + "error" (notify! error-msg "error") + nil))) + +;; ── 3. DELETE (detail) — imperative trigger + navigate protocol ─────────────── +;; The trigger is a confirm dialogue (no values, two-step) → hand-wired. The deleted +;; task is gone, so the success effect is navigate-away (not refetch — that would 404); +;; navigating to /tasks re-reads the board for free via route activation. (defn- on-delete-confirm! [^js e] - ;; The dialogue closes itself on confirm (x-cancel-dialogue do-confirm! → close), - ;; so we only DELETE, then navigate back to /tasks — which re-reads the board. + ;; The dialogue closes itself on confirm (x-cancel-dialogue do-confirm! → close). (-> (api/request "DELETE" (detail-endpoint e) nil) (.then (fn deleted [_deleted] (navigate! w/path-tasks) ;; /tasks activation re-reads the board (notify! "Task deleted" "success"))) (.catch (on-failure "Delete failed")))) -;; ── 4. DELETE (board row) — per-row Delete button ───────────────────────────── +;; ── 4. DELETE (board row) — imperative trigger + invalidate protocol ────────── +;; Per-row Delete buttons are a dynamic list → event delegation (the action can't wrap +;; N per-id buttons). The DELETE is hand-wired, but the refetch goes through the SAME +;; `barebuild-invalidate` protocol the declarative flows use — so coordination is uniform. (defn- on-row-delete! [^js e] (when-let [^js btn (some-> (.-target e) (.closest (str "[" w/attr-delete-id "]")))] (let [id (.getAttribute btn w/attr-delete-id)] - ;; Already on /tasks → no navigation; just DELETE and refresh the board read - ;; so the row disappears (DOM = f(broker value, filters), untouched by hand). (-> (api/request "DELETE" (api-task id) nil) (.then (fn row-deleted [_deleted] - (refresh-data! w/id-tasks-data) + (dispatch-invalidate! api-tasks) ;; board self-matches src → refetches (notify! (str "Task #" id " deleted") "success"))) (.catch (on-failure "Delete failed")))))) -;; ── 5. SETTINGS — settings form submit ──────────────────────────────────────── -(defn- on-settings-save! [^js e] - (.preventDefault e) - ;; PUT the settings AS-IS but coerce page-size to a number: x-form reports it as a - ;; string, and the server merges, so an uncoerced save flips the stored field - ;; number→string. Then refresh the settings broker so the form reflects the PERSISTED - ;; value (server is truth — catches any server-side normalization). - (-> (api/request "PUT" api-settings (with-number (.. e -detail -values) "page-size")) - (.then (fn saved [_saved] - (refresh-data! w/id-settings-data) ;; re-read /api/settings → re-fill form - (notify! "Settings saved" "success"))) - (.catch (on-failure "Save failed")))) - (defn attach-write-handlers! - "Attach the live write-side handlers. Called from core/init! alongside the - read-side wiring (before component registration, so the listeners are live when - the elements upgrade). Each handler performs its write, then re-reads the server." + "Attach the live write-side wiring. Called from core/init! alongside the read-side + wiring (before component registration, so the listeners are live when the elements + upgrade). CREATE / UPDATE / SETTINGS are DECLARATIVE — the barebuild-action elements + do the fetch + invalidation; we only react to barebuild-action-state for the UI side- + effects they don't cover. The two DELETES are hand-wired triggers (no values / dynamic + rows) that coordinate via the same navigate / invalidate protocols." [] - (let [^js create-action (.querySelector js/document "#create-action") - ^js edit-form (.querySelector js/document w/id-edit-task-form) - ^js confirm (.querySelector js/document w/id-delete-confirm) - ^js table (.querySelector js/document w/id-tasks-table) - ^js settings-form (.querySelector js/document w/id-settings-form)] - ;; CREATE is declarative — we only listen to the action's published state. - (.addEventListener create-action w/ev-action-state on-create-state!) - (.addEventListener edit-form w/ev-form-submit on-edit-submit!) - (.addEventListener confirm w/ev-confirm on-delete-confirm!) - ;; Board row Delete buttons are delegated through the table (press bubbles). - (.addEventListener table w/ev-press on-row-delete!) - (.addEventListener settings-form w/ev-form-submit on-settings-save!))) + (let [^js create-action (.querySelector js/document "#create-action") + ^js edit-action (.querySelector js/document "#edit-action") + ^js settings-action (.querySelector js/document "#settings-action") + ^js confirm (.querySelector js/document w/id-delete-confirm) + ^js table (.querySelector js/document w/id-tasks-table)] + (.addEventListener create-action w/ev-action-state on-create-state!) + (.addEventListener edit-action w/ev-action-state (toasting-state-handler "Task updated" "Update failed")) + (.addEventListener settings-action w/ev-action-state (toasting-state-handler "Settings saved" "Save failed")) + ;; Deletes: hand-wired triggers, protocol coordination. + (.addEventListener confirm w/ev-confirm on-delete-confirm!) + (.addEventListener table w/ev-press on-row-delete!))) diff --git a/barebuild/docs/write-side-design-notes.md b/barebuild/docs/write-side-design-notes.md index 685b8bc9..4bfa757b 100644 --- a/barebuild/docs/write-side-design-notes.md +++ b/barebuild/docs/write-side-design-notes.md @@ -298,26 +298,50 @@ hand-wiring (a listener, no wrapper) never imposes. the server default; the action can't, so the ported create writes `status: ""`. There is no values-transform hook. (Settings' `with-number` coercion is the same class of gap.) -### Flows NOT ported, with the reason (the analysis held) -- **Update / settings** (form flows): portable like create, but `action`/`src` are **dynamic** - (`/api/tasks/:id`) and must be set imperatively per route — and settings hits the number-coercion - gap. -- **Delete (detail confirm)** and **delete (board row):** **poor fit.** The triggers carry **no - values** (a confirm event; a delegated row button), so the action's values-required submit - path skips; and the row case is dynamically-rendered per-id. Event delegation (hand-wired) is - the right tool; the containment-action is not. - -### Provisional verdict -The declarative path is **real and worth it for the canonical "form POST → refetch a list"** -— once the element bugs above are fixed. But across a real write surface it is **hybrid**: -declarative for fetch+invalidate, imperative for side-effects (toast/modal/reset/navigate), -dynamic URLs, payload cleaning, and non-form triggers. Whether the markup wrapper + the -`barebuild-action-state` listener is *nicer to write* than the hand-wired `fetch`-then-refresh -is a genuine toss-up for anything past the canonical flow. The strongest concrete asks the port -surfaced, if these graduate: (a) a **values-transform hook** on the action (blanks/coercion), -(b) first-class **dynamic `action`/`src`** ergonomics, (c) a **no-values / imperative trigger** -path for deletes. Without those, the elements cover the easy half and leave the awkward half -hand-wired. +### The full port (all five flows) — what each became +The whole write side was ported (Playwright e2e 14/14 against `bb serve`): +- **Create / Update / Settings — DECLARATIVE.** Each is a `` wrapping the + form + a child ``. `write_side` shrank to one + `barebuild-action-state` listener per flow doing only the uncovered side-effects (create: + close modal + reset + toast; update/settings: toast). **Update needs one imperative line** + — `detail/on-route-change` sets the action's dynamic `action`/`src` to `/api/tasks/:id` + (the action attribute can't be a static literal for a param route). +- **Delete (detail) / Delete (board row) — HAND-WIRED TRIGGER, PROTOCOL COORDINATION.** The + action can't drive these (a confirm dialogue and N per-id row buttons carry no form values; + the row case is a dynamic list → event delegation). So the **trigger + DELETE stay + hand-wired**, but the **coordination goes through the same document protocols the + declarative flows use**: detail-delete dispatches `barebuild-navigate {path}`, row-delete + dispatches `barebuild-invalidate {src}` — the *identical* event `` + emits. So the board refetches the same way whether the write was declarative or hand-wired. + +### The key reframe the port produced: invalidate/navigate are PROTOCOLS, the elements are sugar +`barebuild-invalidate {src}` (document event; a matching `` self-matches by +URL and refetches) and `barebuild-navigate {path}` are **public protocols any code can emit**. +`` is just a declarative emitter of the first. Once you see this, +"DELETE doesn't fit ``" stops being a gap: DELETE was never an action-shaped +problem (no form, no values, navigate-or-remove). Its **trigger** is correctly hand-wired; its +**coordination** is uniformly declarative. The demo ends coherent — every write +invalidates-or-navigates identically; only the trigger varies, by necessity. + +### Residual gaps (real, unfixed by the port) +- **Payload cleanliness.** The action JSON-encodes `event.detail.values` **as-is** — no + transform hook. So ported create writes `status: ""` (the `without-blanks` job) and ported + settings persists `page-size` as a **string** (the `with-number` coercion). Both regressions + vs. the hand-wired version; both need a values-transform hook the element lacks. +- **Slotted-wrapper placement.** Mind where the wrapper goes (wrap the *modal*, not the form) + or `::slotted` layout breaks — a cost hand-wiring (a listener, no wrapper) never imposes. + +### Verdict +The declarative path is **real and a genuine win for "form → server → refetch a list/record"** +(create/update/settings), once the two element bugs are fixed. The **protocol reframe makes the +whole surface coherent**, including the hand-wired deletes. What remains hybrid is intrinsic: +the *trigger* is declarative only for forms (confirm dialogues and dynamic-list buttons are +correctly hand-wired), and the *payload* still needs a transform the action can't do. The three +concrete asks if these graduate: (a) **document `barebuild-invalidate` / `barebuild-navigate` as +public protocols** with the elements as sugar (smallest, highest-value commitment — it's what +made delete coherent); (b) a **values-transform hook** on the action (blanks/coercion); (c) +accept that **non-form triggers stay hand-wired** and lean on (a) for their coordination rather +than stretching the action to swallow them. --- diff --git a/docs/barebuild-action.md b/docs/barebuild-action.md index 9f7eeb71..f58e2485 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 From 606dcab0718835467205d37052bb135d61c99b96 Mon Sep 17 00:00:00 2001 From: Alexander van Elsas <58037137+avanelsas@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:16:56 +0200 Subject: [PATCH 05/12] refactor(barebuild): review + audit pass on the write-side alpha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds in the post-review refactor and the final-audit fixes; keeps the branch internally clean and CI-green (still an unmerged alpha — see below). Post-review (shared abstractions + the payload seam): - Extract barebuild/listeners.cljs: the bind/detach/rebind listener mechanics shared by barebuild-action (host) and barebuild-invalidate-on (parent), so the two can't drift. lifecycle.cljs stays the shared fetch/state-transition shell. - barebuild-action gains a `valuesTransform` public property — the seam for payload hygiene the action can't itself know. Closes the port's payload-cleanliness gap: the demo installs without-blanks (create) + with-number (settings) through it, so create no longer POSTs status:"" and settings persists page-size as a number. (update gets none — PUT/merge treats a blank as a deliberate clear.) - action `name` is trimmed (get-attr-trimmed) to match invalidate-on's when-name. Audit fixes: - Boundary-check CI was failing on the literal word "querySelector" in a comment (the code has none); reworded so `check-barebuild-boundary.bb` passes. - do-submit! now guards a nil body (a valuesTransform returning nil, or .submit(nil)) — no more silent "null"-body POST; the imperative entry gets the same guard as the event path. - Dedupe the "httpStatus" state-field literal into protocol/field-http-status (used by both barebuild-data and barebuild-action model constructors). - Drop the now-stale "page-size persists as a string" comment in the demo index.html. NOT YET MERGEABLE TO MAIN (deliberate): main has no alpha dist-tag lane, so these elements would ship on the stable `latest` tag on the next release. Keep as the long-lived alpha branch until the alpha-publish machinery exists. clj-kondo 0/0; du-discipline + barebuild-boundary pass; release lib (Closure) 0 warnings; karma 5188; demo e2e 14/14. Co-Authored-By: Claude Opus 4.8 (1M context) --- barebuild/demo-app/e2e/write-paths.spec.ts | 14 ++- barebuild/demo-app/public/index.html | 6 +- barebuild/demo-app/src/demo_app/detail.cljs | 20 ++-- .../demo-app/src/demo_app/write_side.cljs | 50 +++++++++- barebuild/docs/write-side-design-notes.md | 58 +++++++---- docs/barebuild-action.md | 1 + docs/barebuild-invalidate-on.md | 5 +- .../components/barebuild/lifecycle.cljs | 2 + .../components/barebuild/listeners.cljs | 37 +++++++ .../components/barebuild/protocol.cljs | 26 +++-- .../barebuild_action/barebuild_action.cljs | 97 +++++++++++++------ .../components/barebuild_action/model.cljs | 9 +- .../barebuild_data/barebuild_data.cljs | 29 ++++-- .../components/barebuild_data/model.cljs | 4 +- .../barebuild_invalidate_on.cljs | 25 ++--- .../barebuild_action_test.cljs | 52 ++++++++++ .../barebuild_data/barebuild_data_test.cljs | 35 +++++++ 17 files changed, 362 insertions(+), 108 deletions(-) create mode 100644 src/baredom/components/barebuild/listeners.cljs diff --git a/barebuild/demo-app/e2e/write-paths.spec.ts b/barebuild/demo-app/e2e/write-paths.spec.ts index e2aa4958..21b52c48 100644 --- a/barebuild/demo-app/e2e/write-paths.spec.ts +++ b/barebuild/demo-app/e2e/write-paths.spec.ts @@ -25,9 +25,11 @@ test('create: barebuild-action POSTs and invalidate-on refetches the board', asy const created = after.find((t: { title: string }) => t.title === 'E2E declarative task'); expect(created).toBeTruthy(); expect(after.length).toBe(before + 1); - // NOTE: created.status comes through "" — the action JSON-encodes the form values - // as-is and cannot blank-strip (the hand-wired version used without-blanks). A - // documented payload-cleanliness gap; see write-side-design-notes.md. + // Payload hygiene restored: write_side installs a `valuesTransform` (without-blanks) + // on #create-action, so an unset status no longer POSTs "" and the server default + // {status:"todo"} applies. (Earlier alpha shipped this as a gap; the transform seam + // on barebuild-action closed it — see write-side-design-notes.md.) + expect(created.status).toBe('todo'); }); test('update: barebuild-action PUTs the edit form (dynamic /api/tasks/:id)', async ({ page, request }) => { @@ -47,10 +49,16 @@ test('settings: barebuild-action PUTs and invalidate-on refetches', async ({ pag .poll(() => page.evaluate(() => (document.querySelector('#settings-form [name="theme"]') as { value?: string })?.value)) .toBe('system'); await page.evaluate(() => { (document.querySelector('#settings-form [name="theme"]') as { value: string }).value = 'dark'; }); + await page.fill('#settings-form x-form-field[name="page-size"] input', '25'); await page.click('#settings-form x-button[type="submit"]'); await expect .poll(async () => (await (await request.get('/api/settings')).json()).theme) .toBe('dark'); + // Payload hygiene restored: #settings-action's valuesTransform (with-number) coerces + // page-size, so the merge endpoint stores a NUMBER, not the string the control reports. + const settings = await (await request.get('/api/settings')).json(); + expect(settings['page-size']).toBe(25); + expect(typeof settings['page-size']).toBe('number'); }); test('delete (row): hand-wired DELETE + barebuild-invalidate refetches the board', async ({ page, request }) => { diff --git a/barebuild/demo-app/public/index.html b/barebuild/demo-app/public/index.html index 4aab4578..35dad322 100644 --- a/barebuild/demo-app/public/index.html +++ b/barebuild/demo-app/public/index.html @@ -152,9 +152,9 @@

    Settings

    + invalidate-on refetches it on success. write_side toasts via action-state, + and installs a `valuesTransform` (with-number) on this action so page-size + persists as a number, not a string. --> diff --git a/barebuild/demo-app/src/demo_app/detail.cljs b/barebuild/demo-app/src/demo_app/detail.cljs index 80546e7a..70a1ff34 100644 --- a/barebuild/demo-app/src/demo_app/detail.cljs +++ b/barebuild/demo-app/src/demo_app/detail.cljs @@ -33,6 +33,17 @@ ;; Named, event-only handlers (resolve handles from `currentTarget`), so ;; `init-detail!` reads as a wiring list — matching write_side.cljs. +(defn- point-edit-at! + "Point all three /tasks/:id consumers at `endpoint` IN LOCKSTEP, from one call site so + they cannot drift to different ids: the detail read broker (re-read on activation), the + edit action's PUT target, and the edit invalidate-on's refetch src. The dynamic + /api/tasks/:id URL is the one imperative bit the declarative update needs — the action's + `action` attribute can't be a static literal for a param route." + [^js route endpoint] + (set! (.-src (.querySelector route w/id-detail-data)) endpoint) + (set! (.-action (.querySelector route "#edit-action")) endpoint) + (set! (.-src (.querySelector route "#edit-invalidate")) endpoint)) + (defn- on-route-change "Activation → read /api/tasks/. route-change is pushed to every route on each resolution carrying the GLOBAL match (resolved path + the active route's params), @@ -42,14 +53,7 @@ [^js e] (let [id (gobj/get (.. e -detail -params) "id")] (when (and id (= (str w/path-tasks "/" id) (.. e -detail -path))) - (let [^js route (.-currentTarget e) - endpoint (str "/api/tasks/" id)] - (set! (.-src (.querySelector route w/id-detail-data)) endpoint) - ;; Write-side ALPHA: point the edit action (PUT) + its invalidate-on at this task's - ;; endpoint. The dynamic /api/tasks/:id URL is the one imperative bit the declarative - ;; update needs — the action's `action` attribute can't be a static literal here. - (set! (.-action (.querySelector route "#edit-action")) endpoint) - (set! (.-src (.querySelector route "#edit-invalidate")) endpoint))))) + (point-edit-at! (.-currentTarget e) (str "/api/tasks/" id))))) (defn- on-data-state ;; Renders straight from e.detail.state.data — unlike board, detail has no diff --git a/barebuild/demo-app/src/demo_app/write_side.cljs b/barebuild/demo-app/src/demo_app/write_side.cljs index 2e736315..b603400a 100644 --- a/barebuild/demo-app/src/demo_app/write_side.cljs +++ b/barebuild/demo-app/src/demo_app/write_side.cljs @@ -34,7 +34,8 @@ The backend (server/serve.clj, `bb serve`) implements the endpoints: POST /api/tasks · PUT /api/tasks/:id · DELETE /api/tasks/:id · PUT /api/settings" - (:require [demo-app.wiring :as w] + (:require [clojure.string :as str] + [demo-app.wiring :as w] [demo-app.api :as api])) (def ^:private api-tasks "/api/tasks") @@ -56,15 +57,47 @@ (.-src broker))) (defn- dispatch-invalidate! - "Dispatch the document-level `barebuild-invalidate {src}` protocol — the SAME signal - `` emits for the declarative flows. Any - whose src matches refetches. Lets a hand-wired write (a delete the action can't drive) - invalidate exactly like the declarative ones, so the coordination stays uniform." + "Emit the document-level `barebuild-invalidate {src}` PUBLIC protocol — the SAME + contract `` emits for the declarative flows, and which the + library documents as 'any code can dispatch' (the element is just sugar). Any + whose src matches refetches. The demo-app is a separate build, so + it cannot reuse the element's CLJS emitter; it dispatches the documented `{src}` + shape directly. That shape is the stable, versioned protocol — not an internal + detail — so a hand-wired write (a delete the action can't drive) coordinates + exactly like a declarative one." [src] (.dispatchEvent js/document (js/CustomEvent. w/ev-invalidate #js {:bubbles true :composed true :detail #js {:src src}}))) +;; ── Payload hygiene (the action's `valuesTransform` seam) ───────────────────────── +;; barebuild-action JSON-encodes event.detail.values as-is; these are the per-flow +;; transforms x-form's reporting needs, installed on the actions as `.valuesTransform`. + +(defn- without-blanks + "Drop name→value pairs whose value is a blank string. CREATE-ONLY. x-form reports + every named control, defaulting an unset one to \"\", and on a POST a present-but-blank + key shadows the server's default (a blank `status` would override {:status \"todo\"}); + omitting blanks lets the defaults apply. Do NOT use on a PUT/merge endpoint — there a + blank is a deliberate \"clear this field\", and dropping it makes the clear silently fail." + [^js values] + (let [out #js {}] + (doseq [k (js/Object.keys values)] + (let [v (aget values k)] + (when-not (and (string? v) (str/blank? v)) + (aset out k v)))) + out)) + +(defn- with-number + "Return a shallow copy of `values` with key `k` parsed to an integer (left as-is if + unparseable). x-form reports a number control's value as a string; on a PUT/merge that + would otherwise flip the server field's type (e.g. page-size 25 → \"25\") on first save." + [^js values k] + (let [out (js/Object.assign #js {} values) + n (js/parseInt (aget out k) 10)] + (when-not (js/isNaN n) (aset out k n)) + out)) + (defn- navigate! "Programmatic SPA navigation: dispatch the router's `barebuild-navigate` AT the router element, which pushState's `path` and re-resolves. Re-resolving /tasks @@ -162,6 +195,13 @@ (.addEventListener create-action w/ev-action-state on-create-state!) (.addEventListener edit-action w/ev-action-state (toasting-state-handler "Task updated" "Update failed")) (.addEventListener settings-action w/ev-action-state (toasting-state-handler "Settings saved" "Save failed")) + ;; Restore payload hygiene the hand-wired flows had: CREATE blank-strips (so an unset + ;; status doesn't shadow the server default), SETTINGS coerces page-size to a number. + ;; UPDATE is a PUT/merge — values pass AS-IS (a blank is a deliberate field-clear), so + ;; the edit action gets no transform. Set before upgrade is fine: `.valuesTransform` is + ;; a plain public property (no shadowed accessor), read by the action at submit time. + (set! (.-valuesTransform create-action) without-blanks) + (set! (.-valuesTransform settings-action) (fn settings-transform [^js v] (with-number v "page-size"))) ;; Deletes: hand-wired triggers, protocol coordination. (.addEventListener confirm w/ev-confirm on-delete-confirm!) (.addEventListener table w/ev-press on-row-delete!))) diff --git a/barebuild/docs/write-side-design-notes.md b/barebuild/docs/write-side-design-notes.md index 4bfa757b..7d11f120 100644 --- a/barebuild/docs/write-side-design-notes.md +++ b/barebuild/docs/write-side-design-notes.md @@ -293,10 +293,14 @@ hand-wiring (a listener, no wrapper) never imposes. - **Stayed imperative** (a `barebuild-action-state` listener in `write_side`): close the modal, reset the form, success/error toast. The elements cover the *data* round-trip; every *UI* side-effect is still hand-wired. -- **Payload-cleanliness gap (unresolved):** the action JSON-encodes `event.detail.values` - **as-is**. The hand-wired create used `without-blanks` so a blank `status` wouldn't shadow - the server default; the action can't, so the ported create writes `status: ""`. There is no - values-transform hook. (Settings' `with-number` coercion is the same class of gap.) +- **Payload-cleanliness gap (RESOLVED — code review, 2026-06-03):** the action JSON-encodes + `event.detail.values`, but now through an optional `valuesTransform` property — a + `(fn [values] → values)` a consumer sets imperatively. `write_side` installs `without-blanks` + on `#create-action` and `with-number` (page-size) on `#settings-action`, restoring the + hand-wired hygiene: the ported create no longer writes `status: ""` (server default applies) + and settings persists `page-size` as a number. The e2e asserts both (`status === "todo"`, + `typeof page-size === "number"`). UPDATE intentionally sets no transform — its PUT/merge + endpoint treats a blank as a deliberate field-clear. ### The full port (all five flows) — what each became The whole write side was ported (Playwright e2e 14/14 against `bb serve`): @@ -323,25 +327,45 @@ problem (no form, no values, navigate-or-remove). Its **trigger** is correctly h **coordination** is uniformly declarative. The demo ends coherent — every write invalidates-or-navigates identically; only the trigger varies, by necessity. -### Residual gaps (real, unfixed by the port) -- **Payload cleanliness.** The action JSON-encodes `event.detail.values` **as-is** — no - transform hook. So ported create writes `status: ""` (the `without-blanks` job) and ported - settings persists `page-size` as a **string** (the `with-number` coercion). Both regressions - vs. the hand-wired version; both need a values-transform hook the element lacks. -- **Slotted-wrapper placement.** Mind where the wrapper goes (wrap the *modal*, not the form) - or `::slotted` layout breaks — a cost hand-wiring (a listener, no wrapper) never imposes. +### Residual gaps +- **Payload cleanliness — CLOSED (code review, 2026-06-03).** The action gained an optional + `valuesTransform` property (`(fn [values] → values)`, applied before JSON-encoding). The demo + installs `without-blanks` on create and `with-number` on settings, so neither regression + stands: create no longer writes `status: ""`, settings persists `page-size` as a number. +- **Slotted-wrapper placement (still a structural cost).** Mind where the wrapper goes (wrap the + *modal*, not the form) or `::slotted` layout breaks — a cost hand-wiring (a listener, no + wrapper) never imposes. Unchanged by the review. ### Verdict The declarative path is **real and a genuine win for "form → server → refetch a list/record"** (create/update/settings), once the two element bugs are fixed. The **protocol reframe makes the whole surface coherent**, including the hand-wired deletes. What remains hybrid is intrinsic: the *trigger* is declarative only for forms (confirm dialogues and dynamic-list buttons are -correctly hand-wired), and the *payload* still needs a transform the action can't do. The three -concrete asks if these graduate: (a) **document `barebuild-invalidate` / `barebuild-navigate` as -public protocols** with the elements as sugar (smallest, highest-value commitment — it's what -made delete coherent); (b) a **values-transform hook** on the action (blanks/coercion); (c) -accept that **non-form triggers stay hand-wired** and lean on (a) for their coordination rather -than stretching the action to swallow them. +correctly hand-wired). The earlier *payload* gap is now closed by the `valuesTransform` seam +(ask (b), below). The three concrete asks if these graduate: (a) **document `barebuild-invalidate` +/ `barebuild-navigate` as public protocols** with the elements as sugar (smallest, highest-value +commitment — it's what made delete coherent); (b) ~~a **values-transform hook** on the action~~ +**DONE** — `valuesTransform` ships in the alpha (blanks/coercion); (c) accept that **non-form +triggers stay hand-wired** and lean on (a) for their coordination rather than stretching the +action to swallow them. + +### Code-review follow-up (2026-06-03) — what the review changed +A high-effort code review of the branch surfaced ten findings; all were addressed: +- **Crash hardening:** ``'s document `barebuild-invalidate` listener now reads + `detail.src` defensively — a detail-less event from foreign code (the protocol is public) is + ignored instead of throwing. +- **Match key:** invalidation now compares `origin + pathname + query` (was pathname+query), so a + cross-origin `src` to the same path no longer false-matches. +- **Name policy:** the action's `detail.name` is trimmed, matching `invalidate-on`'s `when-name` + whitespace policy (a padded name no longer silently disables name-scoped invalidation). +- **No silent swallow:** the action defers `preventDefault()` until it actually services a submit, + so a submit it skips (no values / no action) isn't swallowed. +- **Payload hygiene:** the `valuesTransform` seam (above). +- **De-duplication:** the listener stash/rebind/detach mechanics shared by the action and + `invalidate-on` now live in `barebuild/lifecycle`. +- **Demo lockstep:** detail's three `/api/tasks/:id` writes go through one `point-edit-at!` helper. +- Plus documentation of the deliberate event-less disconnect reset and the public-protocol + emission in the demo's row-delete. --- diff --git a/docs/barebuild-action.md b/docs/barebuild-action.md index f58e2485..f4a3a426 100644 --- a/docs/barebuild-action.md +++ b/docs/barebuild-action.md @@ -105,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). diff --git a/docs/barebuild-invalidate-on.md b/docs/barebuild-invalidate-on.md index 8140af05..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 `pathname + query` equality** (resolved against the page origin) is used by `` to decide if it should refetch — so `/api/items?page=1` and `?page=2` stay distinct (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. @@ -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 `pathname + query` equality** (each resolved against the page origin): +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. diff --git a/src/baredom/components/barebuild/lifecycle.cljs b/src/baredom/components/barebuild/lifecycle.cljs index 91d10971..1912aa6b 100644 --- a/src/baredom/components/barebuild/lifecycle.cljs +++ b/src/baredom/components/barebuild/lifecycle.cljs @@ -10,6 +10,8 @@ ;; 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 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 index 568b701d..bfc60fa4 100644 --- a/src/baredom/components/barebuild/protocol.cljs +++ b/src/baredom/components/barebuild/protocol.cljs @@ -1,16 +1,22 @@ (ns baredom.components.barebuild.protocol) -;; Single source of truth for the cross-component event-name HANDSHAKE of the -;; BareBuild server-state family. 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 three -;; 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/event-…`. +;; 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 index 8188979c..e5c10720 100644 --- a/src/baredom/components/barebuild_action/barebuild_action.cljs +++ b/src/baredom/components/barebuild_action/barebuild_action.cljs @@ -5,13 +5,15 @@ [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. No querySelector. +;; `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") @@ -51,26 +53,52 @@ :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 el model/attr-name) :state 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`. A blank - (or whitespace-only) `action` logs and no-ops (never an empty-target request — - an untrimmed whitespace `action` would otherwise resolve to the current page)." + "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 (du/get-attr-trimmed el model/attr-action) - method (or (du/get-attr-trimmed el model/attr-method) model/default-method)] - (if-not action - (js/console.error "barebuild-action: missing `action`; skipping submit") - (lifecycle/start-fetch! el action - #js {:method method - :headers #js {"Content-Type" "application/json"} - :body (js/JSON.stringify values)} - (model/submitting-state) - lifecycle-cfg)))) + (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 @@ -95,11 +123,23 @@ (apply gobj/getValueByKeys detail keys))))) (defn- on-submit-event [^js el ^js ev] - (.preventDefault 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 (some? values) - (do-submit! el values) - (js/console.error "barebuild-action: no values at values-path; skipping (no empty-body request)")))) + (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) @@ -107,24 +147,15 @@ (du/setv! el k-submit-handler h) h))) -(defn- detach-listener! [^js el] - (let [^js h (du/getv el k-submit-handler) - bound (du/getv el k-bound-event)] - ;; `bound` is nil or a non-blank event name (set from get-attr-trimmed). - (when (and h bound) - (.removeEventListener el bound h) - (du/setv! el k-bound-event nil)))) - (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] - (detach-listener! 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") - (do (.addEventListener el evt (ensure-handler! el)) - (du/setv! el k-bound-event evt))))) + (listeners/bind-listener! el el evt (ensure-handler! el) listener-cfg)))) ;; ── Lifecycle ───────────────────────────────────────────────────────────────────── (defn- connected! [^js el] @@ -133,8 +164,12 @@ (bind-submit! el)) (defn- disconnected! [^js el] - (detach-listener! 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] diff --git a/src/baredom/components/barebuild_action/model.cljs b/src/baredom/components/barebuild_action/model.cljs index 716d59e3..16c92924 100644 --- a/src/baredom/components/barebuild_action/model.cljs +++ b/src/baredom/components/barebuild_action/model.cljs @@ -57,6 +57,11 @@ :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 @@ -79,7 +84,7 @@ 204 body); `status` the HTTP status code." [response status] (let [s #js {:phase phase-success :response response}] - (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 @@ -87,5 +92,5 @@ 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_data/barebuild_data.cljs b/src/baredom/components/barebuild_data/barebuild_data.cljs index 904243b4..1294d0b7 100644 --- a/src/baredom/components/barebuild_data/barebuild_data.cljs +++ b/src/baredom/components/barebuild_data/barebuild_data.cljs @@ -80,24 +80,30 @@ (du/setv! el k-refresh-handler h) (.addEventListener el model/event-data-refresh h))) -;; ── Invalidate listener (on document; matched by URL pathname + query) ─────────── +;; ── Invalidate listener (on document; matched by URL origin + pathname + query) ── (defn- match-key - "The match key of `s` resolved against the page origin: `pathname + search`, or - nil for a blank/invalid src. Resolving against the origin makes a relative `src` - and an absolute one to the same path equal; including the query string keeps two - parameterised resources on the same path distinct (`/items?page=1` ≠ - `/items?page=2`) — exactly the equality `` documents." + "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 (.-pathname u) (.-search u))) + (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 - ;; pathname+query equality), re-read. A blank/mismatched src is ignored. + ;; 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 (.. e -detail -src)))) + (when (and own (= own (match-key (some-> e .-detail .-src)))) (fetch! el)))) (defn- ensure-invalidate-handler! [^js el] @@ -123,7 +129,10 @@ (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. + ;; 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)) diff --git a/src/baredom/components/barebuild_data/model.cljs b/src/baredom/components/barebuild_data/model.cljs index fd243467..280a810c 100644 --- a/src/baredom/components/barebuild_data/model.cljs +++ b/src/baredom/components/barebuild_data/model.cljs @@ -74,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 @@ -82,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 index 84030e97..0960da65 100644 --- a/src/baredom/components/barebuild_invalidate_on/barebuild_invalidate_on.cljs +++ b/src/baredom/components/barebuild_invalidate_on/barebuild_invalidate_on.cljs @@ -2,6 +2,7 @@ (: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 @@ -61,34 +62,28 @@ (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- detach! [^js el] - (let [^js h (du/getv el k-handler) - ^js node (du/getv el k-source-node) - bound (du/getv el k-bound-event)] - ;; `bound` is nil or a non-blank event name (set from get-attr-trimmed-or-default). - (when (and h node bound) - (.removeEventListener node bound h) - (du/setv! el k-source-node nil) - (du/setv! el k-bound-event nil)))) - (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] - (detach! 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") - (do (.addEventListener parent evt (ensure-handler! el)) - (du/setv! el k-source-node parent) - (du/setv! el k-bound-event evt))))) + (listeners/bind-listener! el parent evt (ensure-handler! el) listener-cfg)))) ;; ── Lifecycle ───────────────────────────────────────────────────────────────────── (defn- connected! [^js el] @@ -97,7 +92,7 @@ (bind-source! el)) (defn- disconnected! [^js el] - (detach! 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) diff --git a/test/baredom/components/barebuild_action/barebuild_action_test.cljs b/test/baredom/components/barebuild_action/barebuild_action_test.cljs index 0b666ee1..b953c4c9 100644 --- a/test/baredom/components/barebuild_action/barebuild_action_test.cljs +++ b/test/baredom/components/barebuild_action/barebuild_action_test.cljs @@ -257,3 +257,55 @@ (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_data/barebuild_data_test.cljs b/test/baredom/components/barebuild_data/barebuild_data_test.cljs index 495b75ce..50d836af 100644 --- a/test/baredom/components/barebuild_data/barebuild_data_test.cljs +++ b/test/baredom/components/barebuild_data/barebuild_data_test.cljs @@ -335,6 +335,41 @@ (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 From 163f401364a75d3b0244837cca8c135acc32fa7a Mon Sep 17 00:00:00 2001 From: Alexander van Elsas <58037137+avanelsas@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:19:37 +0200 Subject: [PATCH 06/12] refactor(barebuild-demo): unify the three action-state listeners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create / update / settings flows all publish the same barebuild-action-state, but the phase→toast skeleton was duplicated across on-create-state! and toasting-state-handler. Collapse them into a single action-state-handler factory (toast by phase + optional success-only effect), and isolate create's modal-only side-effect as close-and-reset-new-task! passed in as a value. Edit/settings are inline forms their own invalidate-on refetch re-fills, so they need only a toast. One source of truth for the happy/error path; the per-flow difference now reads off the call site as data rather than separate handler bodies. Verified: clj-kondo clean, shadow-cljs release app (advanced) clean, write-path e2e 4/4. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../demo-app/src/demo_app/write_side.cljs | 59 ++++++++++--------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/barebuild/demo-app/src/demo_app/write_side.cljs b/barebuild/demo-app/src/demo_app/write_side.cljs index b603400a..b76a7e79 100644 --- a/barebuild/demo-app/src/demo_app/write_side.cljs +++ b/barebuild/demo-app/src/demo_app/write_side.cljs @@ -128,31 +128,34 @@ (js/console.error err) (notify! msg "error"))) -;; ── 1. CREATE — DECLARATIVE (barebuild-action + invalidate-on) ───────────────── -;; The elements do submit→POST→refetch /api/tasks (see #create-action in index.html). -;; write_side only reacts to the published barebuild-action-state for the UI side- -;; effects the elements do NOT cover: close the modal, reset the form, toast. -(defn- on-create-state! [^js e] - (case (.. e -detail -state -phase) - "success" (do (when-let [^js m (.querySelector js/document w/id-new-task-modal)] (.hide m)) - (when-let [^js f (.querySelector js/document w/id-new-task-form)] (.reset f)) - (notify! "Task created" "success")) - "error" (notify! "Create failed" "error") - nil)) - -;; ── 2 + 5. UPDATE / SETTINGS — DECLARATIVE (barebuild-action + invalidate-on) ── -;; The elements PUT and refetch (#edit-action / #settings-action in index.html; the edit -;; action's dynamic /api/tasks/:id URL is set in detail/on-route-change). The only side- -;; effect they don't cover is the result toast — so write_side just reacts to the state. -(defn- toasting-state-handler - "A barebuild-action-state listener that toasts `success-msg` / `error-msg` by phase. - Shared by update + settings (their only uncovered side-effect is the toast)." - [success-msg error-msg] - (fn on-state [^js e] - (case (.. e -detail -state -phase) - "success" (notify! success-msg "success") - "error" (notify! error-msg "error") - nil))) +;; ── CREATE / UPDATE / SETTINGS — DECLARATIVE (barebuild-action + invalidate-on) ── +;; All three elements do submit→PUT/POST→refetch (see #create-action / #edit-action / +;; #settings-action in index.html; the edit action's dynamic /api/tasks/:id URL is set in +;; detail/on-route-change). They publish the SAME barebuild-action-state, so write_side +;; reacts with ONE listener shape: toast by phase, plus an optional success-only effect the +;; elements don't cover. Only CREATE has such an effect — its form lives in a modal the board +;; refetch never touches, so on success it must close + blank for reopen. Edit/settings are +;; inline forms their own invalidate-on refetch re-fills, so they need nothing but the toast. +(defn- close-and-reset-new-task! + "CREATE's only uncovered side-effect: close the modal and blank the form so a reopen starts + clean (the board refetch re-renders the list, never this modal form)." + [] + (when-let [^js m (.querySelector js/document w/id-new-task-modal)] (.hide m)) + (when-let [^js f (.querySelector js/document w/id-new-task-form)] (.reset f))) + +(defn- action-state-handler + "Build a barebuild-action-state listener shared by all three form flows: toast + `success-msg` / `error-msg` by phase, running optional `on-success!` (an extra UI effect + the elements don't cover) BEFORE the success toast. The data round-trip is the elements' + job; this is only the residual UI." + ([success-msg error-msg] (action-state-handler success-msg error-msg nil)) + ([success-msg error-msg on-success!] + (fn on-state [^js e] + (case (.. e -detail -state -phase) + "success" (do (when on-success! (on-success!)) + (notify! success-msg "success")) + "error" (notify! error-msg "error") + nil)))) ;; ── 3. DELETE (detail) — imperative trigger + navigate protocol ─────────────── ;; The trigger is a confirm dialogue (no values, two-step) → hand-wired. The deleted @@ -192,9 +195,9 @@ ^js settings-action (.querySelector js/document "#settings-action") ^js confirm (.querySelector js/document w/id-delete-confirm) ^js table (.querySelector js/document w/id-tasks-table)] - (.addEventListener create-action w/ev-action-state on-create-state!) - (.addEventListener edit-action w/ev-action-state (toasting-state-handler "Task updated" "Update failed")) - (.addEventListener settings-action w/ev-action-state (toasting-state-handler "Settings saved" "Save failed")) + (.addEventListener create-action w/ev-action-state (action-state-handler "Task created" "Create failed" close-and-reset-new-task!)) + (.addEventListener edit-action w/ev-action-state (action-state-handler "Task updated" "Update failed")) + (.addEventListener settings-action w/ev-action-state (action-state-handler "Settings saved" "Save failed")) ;; Restore payload hygiene the hand-wired flows had: CREATE blank-strips (so an unset ;; status doesn't shadow the server default), SETTINGS coerces page-size to a number. ;; UPDATE is a PUT/merge — values pass AS-IS (a blank is a deliberate field-clear), so From 5b13919d9f5dd91c0e8ee6dfb09d17ff09f0e75b Mon Sep 17 00:00:00 2001 From: Alexander van Elsas <58037137+avanelsas@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:37:45 +0200 Subject: [PATCH 07/12] chore(release): bump to 4.0.0-alpha.0 (write-side alpha pre-release) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package.json version → 4.0.0-alpha.0 + CHANGELOG entry, on feat/barebuild-write-side. Tagging v4.0.0-alpha.0 will publish on the npm `alpha` dist-tag (per the lane added in #261); `latest` and the stable 3.x line are unaffected. build.clj / deps.edn / README stay on 3.x deliberately — the Clojars deploy is skipped for pre-releases and the README documents the stable install. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++++++++++ package.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9673a4e..7c9df481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to BareDOM will be documented in this file. +## [4.0.0-alpha.0] - 2026-06-03 + +**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.** First preview of the BareBuild **write-side** coordination elements, designed-by-use from the Phase-4 demo port. These are **experimental — attribute shapes may change before 4.0.0** (the no-selectors / values-not-places spine will not). See `barebuild/docs/write-side-design-notes.md`. + +### Added — write-side ALPHA + +- **``** — wraps a submit emitter by containment (a descendant dispatching the configured `submit-event`), JSON-POST/PUT/DELETEs the values, and publishes `.state` + a `barebuild-action-state {name, state}` event per phase transition. Optional `.valuesTransform` property for payload hygiene the action can't itself know (blank-stripping, numeric coercion). +- **``** — placed as a child of a source; on a `when-phase`/`when-name` match it dispatches the document-level `barebuild-invalidate {src}` protocol. +- **``** now also listens at document for `barebuild-invalidate` and refetches when `detail.src` matches its own `src` by exact `URL.pathname` — the invalidation substrate (additive; the read-side contract is unchanged). + ## [3.3.0] - 2026-05-26 Three new framework adapters ship alongside the existing React and Angular adapters: **Vue 3**, **Svelte 5**, and **SolidJS** are now officially supported. BareDOM users of every major JS framework can now install a typed wrapper package that adds framework-idiomatic props, events, and ref handling on top of the same underlying web components. diff --git a/package.json b/package.json index f973dac3..ce647634 100644 --- a/package.json +++ b/package.json @@ -598,7 +598,7 @@ "bugs": { "url": "https://github.com/avanelsas/baredom/issues" }, - "version": "3.3.0", + "version": "4.0.0-alpha.0", "sideEffects": [ "./dist/*.js" ], From 96934e6670cb01049ba092a017082b5a1ee1e536 Mon Sep 17 00:00:00 2001 From: Alexander van Elsas <58037137+avanelsas@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:48:00 +0200 Subject: [PATCH 08/12] feat(barebuild-demo): independent CORS backend proving server-agnosticism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a second, INDEPENDENT backend for the demo so the BareBuild elements are shown to work against any server honouring the contract — not just the original babashka one. The elements are vanilla fetch + CustomEvents holding no client state (DOM = f(server value)), so a backend is valid iff it speaks the contract and is the source of truth. - server/serve_api.clj: API-only + CORS server (port 3001), written from scratch (shares no code with serve.clj). Mirrors the contract exactly: POST 201 + status:"todo" default, PUT/PATCH/settings merge, DELETE 204, 404/405, monotonic ids. `bb serve-api` task added. - server/API-CONTRACT.md: the contract as a standalone spec, so a third (cross- stack) server can be dropped in with no guesswork. - Cross-origin switch (CLJS-only): wiring/api-base reads ?api once, normalizes (strips trailing slash, warns on a missing scheme), and every API URL flows through one w/api chokepoint. write_side re-points the declarative create/ settings actions at the base only in cross-origin mode; the default single- origin path leaves the relative HTML literals untouched. - e2e: playwright.config boots a second webServer (bb serve-api); xorigin.spec.ts proves a page on :3000 drives the :3001 backend over CORS (incl. trailing-slash base + the settings preflight), enforcing the agnosticism claim in CI. serve.clj, the library, and the single-origin bb serve / e2e path are untouched. Verified: barebuild-boundary check, clj-kondo, shadow-cljs release app (advanced), bb load of serve_api.clj, e2e 16/16 (incl. cross-origin). Co-Authored-By: Claude Opus 4.8 (1M context) --- barebuild/demo-app/bb.edn | 3 + barebuild/demo-app/e2e/xorigin.spec.ts | 54 +++++++ barebuild/demo-app/playwright.config.ts | 42 +++-- barebuild/demo-app/public/index.html | 8 + barebuild/demo-app/server/API-CONTRACT.md | 90 +++++++++++ barebuild/demo-app/server/serve_api.clj | 146 ++++++++++++++++++ barebuild/demo-app/src/demo_app/board.cljs | 2 +- barebuild/demo-app/src/demo_app/detail.cljs | 2 +- barebuild/demo-app/src/demo_app/settings.cljs | 2 +- barebuild/demo-app/src/demo_app/wiring.cljs | 30 +++- .../demo-app/src/demo_app/write_side.cljs | 19 ++- 11 files changed, 380 insertions(+), 18 deletions(-) create mode 100644 barebuild/demo-app/e2e/xorigin.spec.ts create mode 100644 barebuild/demo-app/server/API-CONTRACT.md create mode 100644 barebuild/demo-app/server/serve_api.clj diff --git a/barebuild/demo-app/bb.edn b/barebuild/demo-app/bb.edn index 81f5b099..054ae7e2 100644 --- a/barebuild/demo-app/bb.edn +++ b/barebuild/demo-app/bb.edn @@ -11,6 +11,9 @@ :task (do (shell "npm install") (shell "npx shadow-cljs release app") (apply serve/-main *command-line-args*))} + serve-api {:doc "Independent API-ONLY backend (CORS) on http://localhost:3001 — demonstrates BareBuild is server-agnostic. Serves no static files; pair with a page on another origin via ?api=http://localhost:3001." + :requires ([serve-api]) + :task (apply serve-api/-main *command-line-args*)} e2e {:doc "Run the Playwright read-path smoke. playwright.config boots `bb serve`." :task (do (shell "npm install") ;; Browsers for a portable / CI run; cached after first install. diff --git a/barebuild/demo-app/e2e/xorigin.spec.ts b/barebuild/demo-app/e2e/xorigin.spec.ts new file mode 100644 index 00000000..4f380381 --- /dev/null +++ b/barebuild/demo-app/e2e/xorigin.spec.ts @@ -0,0 +1,54 @@ +import { test, expect, request as pwrequest } from '@playwright/test'; + +// Cross-origin E2E — the point of the second backend: BareBuild's orchestration +// elements are vanilla fetch + CustomEvents that hold no client state, so they work +// against ANY server honouring the contract (server/API-CONTRACT.md). Here the page +// is served on :3000 (serve.clj) but ?api= points every API call at the INDEPENDENT +// :3001 server (serve_api.clj, API-only + CORS). Both web servers are booted by +// playwright.config.ts. The trailing slash on ?api also exercises base normalization. + +const API = 'http://localhost:3001'; + +test('create round-trips to the independent :3001 backend and the board refetches (CORS)', async ({ page }) => { + const api = await pwrequest.newContext(); + const before = (await (await api.get(`${API}/api/tasks`)).json()).length; + + // Trailing slash on purpose — proves wiring/api-base normalizes (no //api/tasks). + await page.goto('/tasks?api=http://localhost:3001/'); + await page.waitForSelector('#tasks-table x-table-row'); + + await page.evaluate(() => (document.querySelector('#new-task-modal') as { show(): void }).show()); + const title = page.locator('#new-task-form x-form-field[name="title"] input'); + await title.waitFor({ state: 'visible' }); + await title.fill('Cross-origin task'); + await page.click('#new-task-form x-button[type="submit"]'); + + // POSTed cross-origin and the child + // refetched — so the board (its broker src + the invalidate src both carry the same + // absolute base) shows the new row, all via the elements, no hand-written fetch. + await expect(page.locator('#tasks-table')).toContainText('Cross-origin task'); + + // It landed on :3001 (the independent server), not the :3000 babashka one. + const after = await (await api.get(`${API}/api/tasks`)).json(); + expect(after.find((t: { title: string }) => t.title === 'Cross-origin task')).toBeTruthy(); + expect(after.length).toBe(before + 1); + const on3000 = await (await api.get('http://localhost:3000/api/tasks')).json(); + expect(on3000.find((t: { title: string }) => t.title === 'Cross-origin task')).toBeFalsy(); +}); + +test('settings PUT round-trips to :3001 and refetches (CORS preflight path)', async ({ page }) => { + const api = await pwrequest.newContext(); + + await page.goto('/settings?api=http://localhost:3001'); + await expect + .poll(() => page.evaluate(() => (document.querySelector('#settings-form [name="theme"]') as { value?: string })?.value)) + .toBe('system'); + + await page.evaluate(() => { (document.querySelector('#settings-form [name="theme"]') as { value: string }).value = 'dark'; }); + await page.click('#settings-form x-button[type="submit"]'); + + // The PUT is a cross-origin mutation → triggers a CORS preflight serve_api answers. + await expect + .poll(async () => (await (await api.get(`${API}/api/settings`)).json()).theme) + .toBe('dark'); +}); diff --git a/barebuild/demo-app/playwright.config.ts b/barebuild/demo-app/playwright.config.ts index 61391043..cd21afd3 100644 --- a/barebuild/demo-app/playwright.config.ts +++ b/barebuild/demo-app/playwright.config.ts @@ -1,11 +1,17 @@ import { defineConfig } from '@playwright/test'; -// Playwright config for the BareBuild Tasks demo read-path smoke. +// Playwright config for the BareBuild Tasks demo E2E. // -// The webServer boots `bb serve` (build the app + serve public/ + the JSON API on -// a single origin, port 3000) so the spec drives the real stack with no CORS and -// deep-links resolve via the server's SPA fallback. Bundled chromium is used (what -// `bb e2e` provisions via `npx playwright install chromium`, and what CI installs). +// Two web servers boot, demonstrating BareBuild is server-agnostic: +// - `bb serve` — single origin :3000 (app + JSON API via serve.clj). The +// read/write specs drive this with no CORS; SPA deep-links +// resolve via the server's fallback. +// - `bb serve-api 3001` — the INDEPENDENT API-only + CORS server (serve_api.clj) on +// a separate origin. xorigin.spec.ts drives it via ?api= to +// prove the elements work cross-origin against a different +// backend (see server/API-CONTRACT.md). +// Bundled chromium is used (what `bb e2e` provisions via `npx playwright install +// chromium`, and what CI installs). export default defineConfig({ testDir: './e2e', fullyParallel: false, @@ -22,12 +28,22 @@ export default defineConfig({ // karma CHROMIUM_FLAGS); a no-op locally. launchOptions: { args: process.env.CI ? ['--no-sandbox'] : [] }, }, - webServer: { - command: 'bb serve', - url: 'http://localhost:3000', - reuseExistingServer: !process.env.CI, - // `bb serve` installs deps + does a release build before serving, so give the - // cold path generous headroom. - timeout: 180_000, - }, + webServer: [ + { + command: 'bb serve', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + // `bb serve` installs deps + does a release build before serving, so give the + // cold path generous headroom. + timeout: 180_000, + }, + { + // The independent cross-origin backend for xorigin.spec.ts. API-only, no build, + // so it starts fast; readiness probed on a real endpoint. + command: 'bb serve-api 3001', + url: 'http://localhost:3001/api/tasks', + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + ], }); diff --git a/barebuild/demo-app/public/index.html b/barebuild/demo-app/public/index.html index 35dad322..562da2cb 100644 --- a/barebuild/demo-app/public/index.html +++ b/barebuild/demo-app/public/index.html @@ -178,5 +178,13 @@

    Settings

    + + diff --git a/barebuild/demo-app/server/API-CONTRACT.md b/barebuild/demo-app/server/API-CONTRACT.md new file mode 100644 index 00000000..1939adcf --- /dev/null +++ b/barebuild/demo-app/server/API-CONTRACT.md @@ -0,0 +1,90 @@ +# BareBuild demo — backend API contract + +This is the **whole interface** between the BareBuild demo app and its backend. Implement these +endpoints, in **any language or stack**, and the demo works against your server unchanged. + +That is the point: the BareBuild orchestration elements (``, ``, +``, ``, ``) are vanilla `fetch` + +`CustomEvent`s. They hold **no authoritative client state** — `DOM = f(server value)`: every read +re-fetches, every write re-reads (via invalidation/refresh). So a backend is valid iff it: + +1. **honours this contract**, and +2. **is the single source of truth** (server state IS the state — no client store). + +Two backends in this repo satisfy it, independently: +- `server/serve.clj` — babashka, single-origin (serves the API **and** the static app on `:3000`). +- `server/serve_api.clj` — babashka, **API-only + CORS** on `:3001` (a separate origin), written + from scratch. Run with `bb serve-api`. + +A third, different-stack server (Node/Python/Go/…) implementing the table below would be just as +valid — and is the most convincing demonstration of all. + +## Endpoints + +All request/response bodies are JSON (`Content-Type: application/json`). State is **in-memory and +ephemeral** (reseeded on process start) — do not persist writes to disk. + +| Method | Path | Request body | Success | Response body | +|--------|------|--------------|---------|---------------| +| GET | `/api/tasks` | — | 200 | array of task objects | +| GET | `/api/tasks/:id` | — | 200 | the task (404 `{error:"task not found"}` if absent) | +| POST | `/api/tasks` | partial task | **201** | the created task, with assigned `id` | +| PUT \| PATCH | `/api/tasks/:id` | fields to change | 200 | the **merged** task (404 if absent) | +| DELETE | `/api/tasks/:id` | — | **204** (empty) | — (404 if absent) | +| GET | `/api/settings` | — | 200 | the settings object | +| PUT | `/api/settings` | fields to change | 200 | the **merged** settings object | + +Other behaviours: + +- **POST defaults:** the new task is `merge({status: "todo"}, body, {id})` — i.e. `status` defaults + to `"todo"` but an explicit `status` in the body wins; `id` is server-assigned and never + overridable. The id is a **monotonic counter**: seeded to `max(existing id)` at startup, then + `+1` per POST — so ids strictly increase and are **never reused, even after a delete**. (It is + NOT recomputed as `max(existing ids) + 1` on each POST; after deleting the highest task the next + id is still counter+1, not the freed value.) +- **PUT/PATCH/PUT-settings are MERGES**, not replacements: only supplied fields change; the rest are + preserved. (A blank string is a deliberate "clear this field" — do not strip it.) +- **Unknown `/api/*`** → 404 `{error:"unknown endpoint"}`. **Wrong method** on a known path → 405 + `{error:"method not allowed"}`. +- **Empty/204 bodies are fine:** the client reads the body as text and treats an empty body as + `nil`, so DELETE may return no body. + +## Data shapes + +**Task** (`data/tasks.edn` seeds four of these): + +```json +{ "id": 1, "title": "…", "description": "…", "status": "todo|doing|done", "assignee": "…", "due": "YYYY-MM-DD" } +``` + +`id` is a number; the rest are strings. `description` is optional — the create/edit form sends it +and the reference servers preserve it via plain `merge`, so a backend should store it too rather +than validate it away. + +**Settings** (`data/settings.edn`): + +```json +{ "theme": "system", "page-size": 25, "default-status": "todo" } +``` + +`page-size` is a number. + +## CORS (only needed for a separate-origin backend) + +If the API runs on a **different origin** than the page (as `serve_api.clj` does — API on `:3001`, +page on `:3000`), respond to every request with: + +``` +Access-Control-Allow-Origin: * +Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS +Access-Control-Allow-Headers: Content-Type +``` + +and answer the `OPTIONS` preflight (which a `PUT`/`PATCH`/`DELETE` + JSON body triggers) with `204`. +No credentials are sent, so `*` is correct. + +**URL-match note:** `` decides whether a `barebuild-invalidate` applies by exact +**origin + pathname + query** equality. So when the page drives a cross-origin API, the data +broker's `src` **and** the matching `` must both carry the **same +absolute base** (see the demo's `?api=` switch). A relative `src` resolves to the page origin and +would not match an absolute cross-origin invalidate. diff --git a/barebuild/demo-app/server/serve_api.clj b/barebuild/demo-app/server/serve_api.clj new file mode 100644 index 00000000..061f07a3 --- /dev/null +++ b/barebuild/demo-app/server/serve_api.clj @@ -0,0 +1,146 @@ +(ns serve-api + "Independent BareBuild demo backend — an API-ONLY babashka + http-kit server that + reimplements the demo's JSON contract (see server/API-CONTRACT.md) FROM SCRATCH, on + a separate port (default 3001) with permissive CORS. + + It deliberately shares NO code with serve.clj. That is the whole point: the BareBuild + orchestration elements are vanilla `fetch` + CustomEvents that hold no authoritative + client state (DOM = f(server value); every read re-fetches, every write re-reads). So + they work against ANY server that (a) honours the contract and (b) holds the state — + not just the original babashka one. A second, INDEPENDENT implementation demonstrates + that; a thin wrapper around serve.clj's handler would only show 'same server, new port'. + + Run from barebuild/demo-app/: `bb serve-api [port]` (default 3001). + Pair it with a page served on a DIFFERENT origin (e.g. `bb serve` on 3000) loaded as + `http://localhost:3000/?api=http://localhost:3001` to drive it cross-origin over CORS. + + State is two in-memory atoms seeded from data/*.edn, ephemeral per process (resets on + restart) — matching serve.clj's 'the server holds truth' stance. Boundary note: like + serve.clj, every path resolves from (fs/cwd) (the demo-app dir under `bb`), never a + parent-relative path." + (:require [org.httpkit.server :as http] + [cheshire.core :as json] + [babashka.fs :as fs] + [clojure.edn :as edn] + [clojure.string :as str])) + +;; ── In-memory state (seeded in -main; ephemeral per process) ───────────────── +(defonce ^:private tasks (atom [])) +(defonce ^:private settings (atom {})) +;; A standalone next-id atom (re-derived from the seed in -main) hands out a unique +;; id per POST without locking the task list — same rationale as serve.clj. +(defonce ^:private next-id (atom 0)) + +(defn- read-edn + "Read an EDN file under the demo-app working dir (e.g. \"data/tasks.edn\")." + [rel] + (edn/read-string (slurp (str (fs/path (fs/cwd) rel))))) + +;; ── CORS ───────────────────────────────────────────────────────────────────── +;; / send no credentials (plain fetch), so `*` is +;; correct. A PUT/PATCH/DELETE with an application/json body triggers a preflight, so +;; OPTIONS must be answered and Content-Type must be in Allow-Headers. +(def ^:private cors-headers + {"Access-Control-Allow-Origin" "*" + "Access-Control-Allow-Methods" "GET, POST, PUT, PATCH, DELETE, OPTIONS" + "Access-Control-Allow-Headers" "Content-Type" + ;; Cache the preflight so each cross-origin PUT/PATCH/DELETE doesn't re-OPTIONS. + "Access-Control-Max-Age" "600"}) + +(defn- with-cors [resp] + (update resp :headers merge cors-headers)) + +;; ── HTTP helpers ───────────────────────────────────────────────────────────── +(defn- json-response [status body] + {:status status + :headers {"Content-Type" "application/json"} + :body (json/generate-string body)}) + +(defn- parse-body + "Parse a JSON request body into a map with keyword keys (nil if absent/blank)." + [req] + (when-let [b (:body req)] + (let [s (slurp b)] + (when-not (str/blank? s) + (json/parse-string s true))))) + +(defn- task-id-from + "Parse the numeric id out of /api/tasks/, or nil." + [uri] + (some-> (re-matches #"/api/tasks/(\d+)" uri) second parse-long)) + +(defn- find-task [id] + (first (filter #(= id (:id %)) @tasks))) + +(defn- update-task! + "Merge fields into the task with id; return the updated task or nil if missing." + [id fields] + (when (find-task id) + (swap! tasks (fn [ts] (mapv #(if (= id (:id %)) (merge % fields {:id id}) %) ts))) + (find-task id))) + +;; ── API routing (the contract; see server/API-CONTRACT.md) ─────────────────── +(defn- api-handler [method uri req] + (let [id (task-id-from uri)] ; the /api/tasks/ id, or nil — computed once + (cond + (= uri "/api/tasks") + (case method + :get (json-response 200 @tasks) + :post (let [body (parse-body req) + new-id (swap! next-id inc) + task (merge {:status "todo"} body {:id new-id})] + (swap! tasks conj task) + (json-response 201 task)) + (json-response 405 {:error "method not allowed"})) + + (= uri "/api/settings") + (case method + :get (json-response 200 @settings) + ;; swap! is the atomic read-modify-write (a reset! of (merge @settings …) races + ;; under http-kit's worker pool); it also returns the just-committed value. + :put (json-response 200 (swap! settings merge (parse-body req))) + (json-response 405 {:error "method not allowed"})) + + id + (case method + :get (if-let [t (find-task id)] + (json-response 200 t) + (json-response 404 {:error "task not found"})) + (:put :patch) (if-let [t (update-task! id (parse-body req))] + (json-response 200 t) + (json-response 404 {:error "task not found"})) + :delete (if (find-task id) + (do (swap! tasks (fn [ts] (vec (remove #(= id (:id %)) ts)))) + {:status 204}) + (json-response 404 {:error "task not found"})) + (json-response 405 {:error "method not allowed"})) + + :else (json-response 404 {:error "unknown endpoint"})))) + +;; ── Top-level handler: CORS preflight + API-only (no static serving) ───────── +(defn- handler [req] + (let [uri (:uri req) + method (:request-method req)] + (with-cors + (cond + ;; CORS preflight — answer before routing so any /api/* path is reachable. + (= method :options) {:status 204} + (str/starts-with? uri "/api/") (api-handler method uri req) + ;; API-only: the page is served from ANOTHER origin, not here. + :else (json-response 404 {:error "api-only server; load the app from its own origin"}))))) + +;; ── Entry point ────────────────────────────────────────────────────────────── +(defn -main [& args] + (let [port (or (some-> (first args) parse-long) + (some-> (System/getenv "PORT") parse-long) + 3001)] + (reset! tasks (vec (read-edn "data/tasks.edn"))) + (reset! settings (read-edn "data/settings.edn")) + (reset! next-id (apply max 0 (map :id @tasks))) + (http/run-server #'handler {:port port}) + (println (str "BareBuild demo — INDEPENDENT API-only backend (CORS) → http://localhost:" port)) + (println " read: GET /api/tasks GET /api/tasks/:id GET /api/settings") + (println " write: POST /api/tasks PUT|PATCH|DELETE /api/tasks/:id PUT /api/settings") + (println (str " CORS: * (no static). Pair with a page on another origin via " + "?api=http://localhost:" port)) + @(promise))) diff --git a/barebuild/demo-app/src/demo_app/board.cljs b/barebuild/demo-app/src/demo_app/board.cljs index 3638bca3..cbeccf75 100644 --- a/barebuild/demo-app/src/demo_app/board.cljs +++ b/barebuild/demo-app/src/demo_app/board.cljs @@ -95,7 +95,7 @@ [^js e] (when (= w/path-tasks (.. e -detail -path)) (let [^js route (.-currentTarget e)] - (set! (.-src (.querySelector route w/id-tasks-data)) "/api/tasks")))) + (set! (.-src (.querySelector route w/id-tasks-data)) (w/api "/api/tasks"))))) (defn- on-data-state "Toggle the loading/error surfaces from event.detail.state's phase; on `loaded`, diff --git a/barebuild/demo-app/src/demo_app/detail.cljs b/barebuild/demo-app/src/demo_app/detail.cljs index 70a1ff34..fd539365 100644 --- a/barebuild/demo-app/src/demo_app/detail.cljs +++ b/barebuild/demo-app/src/demo_app/detail.cljs @@ -53,7 +53,7 @@ [^js e] (let [id (gobj/get (.. e -detail -params) "id")] (when (and id (= (str w/path-tasks "/" id) (.. e -detail -path))) - (point-edit-at! (.-currentTarget e) (str "/api/tasks/" id))))) + (point-edit-at! (.-currentTarget e) (w/api (str "/api/tasks/" id)))))) (defn- on-data-state ;; Renders straight from e.detail.state.data — unlike board, detail has no diff --git a/barebuild/demo-app/src/demo_app/settings.cljs b/barebuild/demo-app/src/demo_app/settings.cljs index 93ea9cd7..b26c27af 100644 --- a/barebuild/demo-app/src/demo_app/settings.cljs +++ b/barebuild/demo-app/src/demo_app/settings.cljs @@ -14,7 +14,7 @@ (defn- on-route-change [^js e] (when (= w/path-settings (.. e -detail -path)) (let [^js route (.-currentTarget e)] - (set! (.-src (.querySelector route w/id-settings-data)) "/api/settings")))) + (set! (.-src (.querySelector route w/id-settings-data)) (w/api "/api/settings"))))) (defn- on-data-state [^js e] (let [^js route (.-currentTarget e) diff --git a/barebuild/demo-app/src/demo_app/wiring.cljs b/barebuild/demo-app/src/demo_app/wiring.cljs index ad23eb88..ecde3c67 100644 --- a/barebuild/demo-app/src/demo_app/wiring.cljs +++ b/barebuild/demo-app/src/demo_app/wiring.cljs @@ -5,7 +5,35 @@ just never fires, with no error — and the same risk applies here. Several ids and paths are also spelled in more than one place; keeping them here de-complects the spelling of a wire from the wiring. (File-local, single-use ids stay inline - in their namespace — they are not duplicated, and they read 1:1 with index.html.)") + in their namespace — they are not duplicated, and they read 1:1 with index.html.)" + (:require [clojure.string :as str])) + +;; ── API base (cross-origin / backend-swap switch) ───────────────────────────── +;; API URLs are RELATIVE by default ("") → they resolve to the PAGE origin, exactly +;; what single-origin `bb serve` needs. Pass `?api=` (e.g. +;; ?api=http://localhost:3001) to drive a SEPARATE backend cross-origin — the +;; independent `bb serve-api` server (see server/API-CONTRACT.md). +;; +;; This is the SINGLE place the base is read and normalized — there is no JS boot +;; script and no `window` global. Read once at load (a backend swap is a page reload, +;; not a live mutation). A trailing slash is stripped so `(str api-base "/api/x")` can +;; never produce a double slash (which the server 404s — and which still origin-matches +;; the invalidate, so the failure would be silent). Every API URL flows through `api` +;; below, so read brokers, write actions, and invalidate-on srcs all prefix uniformly. +(def api-base + (let [raw (or (.get (js/URLSearchParams. (.. js/window -location -search)) "api") "") + base (str/replace raw #"/+$" "")] + (when (and (seq base) (not (str/includes? base "://"))) + (js/console.warn (str "demo-app: ?api=\"" base "\" has no scheme; it resolves against the " + "page origin, not a separate backend. Use e.g. http://localhost:3001"))) + base)) + +(defn api + "Prefix an API path with the configured base (empty default = relative URL → page + origin). The single home for API-URL construction so a cross-origin swap stays + uniform across the read brokers, the write actions, and the invalidate-on srcs." + [path] + (str api-base path)) ;; ── Events ─────────────────────────────────────────────────────────────────── ;; Orchestration (barebuild-*) — listened for on the route element. diff --git a/barebuild/demo-app/src/demo_app/write_side.cljs b/barebuild/demo-app/src/demo_app/write_side.cljs index b76a7e79..0487ba6c 100644 --- a/barebuild/demo-app/src/demo_app/write_side.cljs +++ b/barebuild/demo-app/src/demo_app/write_side.cljs @@ -38,7 +38,7 @@ [demo-app.wiring :as w] [demo-app.api :as api])) -(def ^:private api-tasks "/api/tasks") +(def ^:private api-tasks (w/api "/api/tasks")) (defn- api-task "The single-task endpoint for `id` — used by the board row delete, whose id comes @@ -182,6 +182,17 @@ (notify! (str "Task #" id " deleted") "success"))) (.catch (on-failure "Delete failed")))))) +(defn- point-action-at! + "Re-point a declarative and its child at + `endpoint` (set as attributes; read lazily by the elements after upgrade). Used only + in cross-origin mode so create/settings honour the ?api base through the SAME w/api + chokepoint as everything else — no separate JS prefixer. In the default single-origin + mode the HTML's relative literals stand as the declarative source, untouched." + [^js action endpoint] + (.setAttribute action "action" endpoint) + (when-let [^js inv (.querySelector action "barebuild-invalidate-on")] + (.setAttribute inv "src" endpoint))) + (defn attach-write-handlers! "Attach the live write-side wiring. Called from core/init! alongside the read-side wiring (before component registration, so the listeners are live when the elements @@ -205,6 +216,12 @@ ;; a plain public property (no shadowed accessor), read by the action at submit time. (set! (.-valuesTransform create-action) without-blanks) (set! (.-valuesTransform settings-action) (fn settings-transform [^js v] (with-number v "page-size"))) + ;; Cross-origin only: re-point the declarative create/settings actions (+ their + ;; invalidate-on srcs) at the ?api base via w/api — the single prefixer. The default + ;; single-origin mode leaves the HTML's relative literals as-is. + (when (seq w/api-base) + (point-action-at! create-action (w/api "/api/tasks")) + (point-action-at! settings-action (w/api "/api/settings"))) ;; Deletes: hand-wired triggers, protocol coordination. (.addEventListener confirm w/ev-confirm on-delete-confirm!) (.addEventListener table w/ev-press on-row-delete!))) From afe696318431d2c57cc1e91859ce3f4d8c1566ca Mon Sep 17 00:00:00 2001 From: Alexander van Elsas <58037137+avanelsas@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:46:27 +0200 Subject: [PATCH 09/12] chore(barebuild-demo): point the demo at the published @vanelsas/baredom alpha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `^3.4.0` pin was a placeholder from before the write-side elements shipped, so a plain `npm install` (and thus `bb serve`) failed with ETARGET. The alpha is now on npm (`4.0.0-alpha.0`, `alpha` dist-tag), so bump the pin to `^4.0.0-alpha.0` — the demo resolves from the registry and the one-time local-pack seed is no longer needed. CI's demo-e2e still packs+installs the freshly built lib OVER this pin, so it keeps testing the local build, not the published one. Co-Authored-By: Claude Opus 4.8 (1M context) --- barebuild/demo-app/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/barebuild/demo-app/package.json b/barebuild/demo-app/package.json index 482105e9..4ac93601 100644 --- a/barebuild/demo-app/package.json +++ b/barebuild/demo-app/package.json @@ -8,7 +8,7 @@ "build": "shadow-cljs release app" }, "dependencies": { - "@vanelsas/baredom": "^3.4.0" + "@vanelsas/baredom": "^4.0.0-alpha.0" }, "devDependencies": { "@playwright/test": "^1.50.0", From 774c37425958256d8be3d580854b6c3903dd2d7d Mon Sep 17 00:00:00 2001 From: Alexander van Elsas <58037137+avanelsas@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:45:30 +0200 Subject: [PATCH 10/12] fix(x-select): make .value reflect the current selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit x-select's `.value` returned the value ATTRIBUTE, which a user selection never updates (only the inner , with one disambiguation: a non-empty value set BEFORE its
    Board Settings + +
    + Backend + +
    @@ -179,12 +190,8 @@

    Settings

    - + diff --git a/barebuild/demo-app/server/API-CONTRACT.md b/barebuild/demo-app/server/API-CONTRACT.md index 1939adcf..d170ae2e 100644 --- a/barebuild/demo-app/server/API-CONTRACT.md +++ b/barebuild/demo-app/server/API-CONTRACT.md @@ -86,5 +86,6 @@ No credentials are sent, so `*` is correct. **URL-match note:** `` decides whether a `barebuild-invalidate` applies by exact **origin + pathname + query** equality. So when the page drives a cross-origin API, the data broker's `src` **and** the matching `` must both carry the **same -absolute base** (see the demo's `?api=` switch). A relative `src` resolves to the page origin and -would not match an absolute cross-origin invalidate. +absolute base** (the demo routes every API URL through `demo_app.wiring/api`, selected via the +navbar backend picker). A relative `src` resolves to the page origin and would not match an +absolute cross-origin invalidate. diff --git a/barebuild/demo-app/server/serve_api.clj b/barebuild/demo-app/server/serve_api.clj index 061f07a3..b811e136 100644 --- a/barebuild/demo-app/server/serve_api.clj +++ b/barebuild/demo-app/server/serve_api.clj @@ -11,8 +11,9 @@ that; a thin wrapper around serve.clj's handler would only show 'same server, new port'. Run from barebuild/demo-app/: `bb serve-api [port]` (default 3001). - Pair it with a page served on a DIFFERENT origin (e.g. `bb serve` on 3000) loaded as - `http://localhost:3000/?api=http://localhost:3001` to drive it cross-origin over CORS. + Pair it with a page served on a DIFFERENT origin (e.g. `bb serve` on 3000) and pick this + backend in the demo's navbar picker (or deep-link `?backend=bb-cors`) to drive it + cross-origin over CORS. State is two in-memory atoms seeded from data/*.edn, ephemeral per process (resets on restart) — matching serve.clj's 'the server holds truth' stance. Boundary note: like @@ -141,6 +142,6 @@ (println (str "BareBuild demo — INDEPENDENT API-only backend (CORS) → http://localhost:" port)) (println " read: GET /api/tasks GET /api/tasks/:id GET /api/settings") (println " write: POST /api/tasks PUT|PATCH|DELETE /api/tasks/:id PUT /api/settings") - (println (str " CORS: * (no static). Pair with a page on another origin via " - "?api=http://localhost:" port)) + (println (str " CORS: * (no static). Pick this backend in the demo's navbar picker " + "(or ?backend=bb-cors) on a page served from another origin.")) @(promise))) diff --git a/barebuild/demo-app/src/demo_app/core.cljs b/barebuild/demo-app/src/demo_app/core.cljs index 611fbb91..2d6cce45 100644 --- a/barebuild/demo-app/src/demo_app/core.cljs +++ b/barebuild/demo-app/src/demo_app/core.cljs @@ -8,6 +8,8 @@ namespaces; the write surfaces those namespaces build are wired by hand in write-side (the Phase-4 telemetry seam)." (:require + [demo-app.wiring :as w] + [demo-app.dom :as dom] [demo-app.board :as board] [demo-app.detail :as detail] [demo-app.settings :as settings] @@ -56,12 +58,34 @@ x-cancel-dialogue x-toast x-toaster]] (.init m))) +(defn- init-backend-select! + "Wire the navbar backend picker (#backend-select): populate it from the data-driven + w/backends, reflect the active choice, and on a change persist + reload — the brokers + re-fetch from the new backend cleanly on load. This is the demo's interactive proof of + server-agnosticism (swap the server, the same app works against it). The change guard + ignores the upgrade's echo of the current value, so only a real switch reloads. + + Set before component registration (the picker is parsed but not upgraded): options + + the value ATTRIBUTE are read by x-select on upgrade, and the listener is already live." + [] + (when-let [^js sel (.querySelector js/document "#backend-select")] + (dom/fill-options! sel w/backends nil) + (.setAttribute sel "value" (:value w/active-backend)) + (.addEventListener sel w/ev-select-change + (fn on-backend-change [^js e] + (let [v (.. e -detail -value)] + (when (not= v (:value w/active-backend)) + (w/select-backend! v) + (.reload (.. js/window -location)))))))) + (defn init! [] (board/init-board!) (detail/init-detail!) (settings/init-settings!) ;; Live write-side handlers — create / update / delete / settings (see write_side.cljs). (write-side/attach-write-handlers!) + (w/capture-backend!) ; persist a valid ?backend deep-link so the choice sticks + (init-backend-select!) (register-components!)) (defn reload! [] diff --git a/barebuild/demo-app/src/demo_app/wiring.cljs b/barebuild/demo-app/src/demo_app/wiring.cljs index ecde3c67..ab33f9ca 100644 --- a/barebuild/demo-app/src/demo_app/wiring.cljs +++ b/barebuild/demo-app/src/demo_app/wiring.cljs @@ -8,25 +8,71 @@ in their namespace — they are not duplicated, and they read 1:1 with index.html.)" (:require [clojure.string :as str])) -;; ── API base (cross-origin / backend-swap switch) ───────────────────────────── -;; API URLs are RELATIVE by default ("") → they resolve to the PAGE origin, exactly -;; what single-origin `bb serve` needs. Pass `?api=` (e.g. -;; ?api=http://localhost:3001) to drive a SEPARATE backend cross-origin — the -;; independent `bb serve-api` server (see server/API-CONTRACT.md). +;; ── Backends (data-driven backend registry — the demo's "swap server" picker) ── +;; The demo's whole server-agnosticism story as DATA: each entry is a backend the demo +;; can talk to. Adding one (e.g. the future Node server) is a SINGLE row here + that +;; server implementing server/API-CONTRACT.md on its port with CORS — nothing else +;; changes. `:base` "" = same-origin as the page (the default; works with just +;; `bb serve`). A non-empty `:base` is a SEPARATE origin, so that server must be running +;; AND CORS-enabled (the page is one origin; a different base = cross-origin). `:value` +;; is the option value, the sessionStorage value, and the `?backend=` deep-link id. +;; PORTS: the labels/bases assume the servers' DEFAULT ports (serve.clj :3000, +;; serve_api.clj :3001). Override a server's PORT and you must edit its row here too — +;; there is no shared source of truth between the servers and these labels. +(def backends + [{:value "same-origin" :label "Same-origin · :3000 (babashka)" :base ""} + {:value "bb-cors" :label "CORS · :3001 (babashka serve_api)" :base "http://localhost:3001"} + ;; Add the future Node server here once it implements the contract on :3002 with CORS: + ;; {:value "node-cors" :label "CORS · :3002 (Node)" :base "http://localhost:3002"} + ]) + +(def ^:private backend-storage-key "barebuildDemoBackend") + +(defn- backend-by-value [v] + (first (filter #(= v (:value %)) backends))) + +;; NOTE: `url-backend-param` / `active-backend` / `api-base` read the browser environment at +;; namespace LOAD (not in init!) ON PURPOSE — the backend choice is fixed for the page's +;; lifetime (an epoch); swapping it is a page reload (a new epoch), never a live mutation. It +;; must be a load-time value because write_side's `(def api-tasks (w/api …))` reads api-base +;; at its own load. Don't "fix" this into an init-time read without reworking that chain. ;; -;; This is the SINGLE place the base is read and normalized — there is no JS boot -;; script and no `window` global. Read once at load (a backend swap is a page reload, -;; not a live mutation). A trailing slash is stripped so `(str api-base "/api/x")` can -;; never produce a double slash (which the server 404s — and which still origin-matches -;; the invalidate, so the failure would be silent). Every API URL flows through `api` -;; below, so read brokers, write actions, and invalidate-on srcs all prefix uniformly. -(def api-base - (let [raw (or (.get (js/URLSearchParams. (.. js/window -location -search)) "api") "") - base (str/replace raw #"/+$" "")] - (when (and (seq base) (not (str/includes? base "://"))) - (js/console.warn (str "demo-app: ?api=\"" base "\" has no scheme; it resolves against the " - "page origin, not a separate backend. Use e.g. http://localhost:3001"))) - base)) +;; The `?backend=` deep-link, read ONCE at load (the URL doesn't change between +;; ns-load and init!, so `active-backend` and `capture-backend!` share this single read). +(def ^:private url-backend-param + (.get (js/URLSearchParams. (.. js/window -location -search)) "backend")) + +(defn- store-backend! [v] + (.setItem js/sessionStorage backend-storage-key v)) + +;; The active backend, resolved ONCE at load (a READ, no writes): a VALID `?backend=` +;; deep-link wins, else the remembered sessionStorage choice, else the first (same-origin). +;; Persisting the deep-link is done separately by `capture-backend!` (from core/init!), so +;; resolving the value here has no side effects. +(def active-backend + (or (backend-by-value url-backend-param) + (backend-by-value (.getItem js/sessionStorage backend-storage-key)) + (first backends))) + +(defn capture-backend! + "If the URL carried a VALID `?backend=`, remember it for the tab so the choice + survives in-app navigation (the router drops the query) and reloads. Validates first, so + an unknown/empty `?backend` is ignored rather than persisted as junk. Called from init!." + [] + (when-let [b (backend-by-value url-backend-param)] + (store-backend! (:value b)))) + +(defn select-backend! + "Persist the chosen backend `value` for the tab; `active-backend` reads it on the next + load. The caller reloads to apply it — a backend swap re-initialises every broker + cleanly, rather than live-repointing (which would make the base a place, not a value)." + [value] + (store-backend! value)) + +;; Every API URL flows through `api`. The base is normalised (trailing slash stripped) so a +;; `backends` row pasted with a trailing slash can't produce `//api/x` — which the server +;; 404s while the invalidate still origin-matches (a silent failure). "" → relative → origin. +(def api-base (str/replace (:base active-backend) #"/+$" "")) (defn api "Prefix an API path with the configured base (empty default = relative URL → page