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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 100 additions & 23 deletions src/hyper/actions.clj
Original file line number Diff line number Diff line change
Expand Up @@ -36,25 +36,23 @@
"Client-param symbols like $key/$value work inside h/expr.")}))

(defn build-action-expr
"Build the Datastar/JS expression string for an action.
Always uses Datastar's @post() so that all non-underscore signals are
automatically sent in the request body. When client params are present,
they are URL-encoded into the query string via the hyper.encodeClientParams helper
so the server can read them from query-params.
Optionally injects a custom Datastar expression to conditionally prevent the post.
base-path is prepended to the /hyper/actions endpoint (empty string when not set)."
[action-id used-params js base-path]
"Build the Datastar/JS expression string for an action: hyper's
`@hyperAction(id, clientParams)` (see `hyper.server/action-script`), which
queues the action with the page's signals, the render it was made on and
the next sequence number, and posts the tab's queued actions so the
server runs each once, in the order they were made.
Optionally injects a custom Datastar expression to conditionally prevent
the action. base-path is unused: the client script knows the endpoint."
[action-id used-params js _base-path]
(let [js-injection (when js (str js " && "))]
(if (empty? used-params)
(str js-injection "@post('" base-path "/hyper/actions?action-id=" action-id "')")
(str js-injection "@hyperAction('" action-id "')")
(let [obj-entries (->> used-params
vals
(map (fn [{:keys [js key]}]
(str key ":" js)))
(str/join ","))]
(str js-injection
"@post('" base-path "/hyper/actions?action-id=" action-id
"&' + hyper.encodeClientParams({" obj-entries "}))")))))
(str js-injection "@hyperAction('" action-id "', {" obj-entries "})")))))

(defn register-action!
"Register an action function and return its ID.
Expand All @@ -72,19 +70,98 @@
([app-state* session-id tab-id action-fn action-id]
(register-action! app-state* session-id tab-id action-fn action-id nil))
([app-state* session-id tab-id action-fn action-id opts]
(swap! app-state* (fn [state]
(-> state
(assoc-in [:actions action-id]
(cond-> {:fn action-fn
:session-id session-id
:tab-id tab-id}
(:as opts) (assoc :as (:as opts))))
(update-in [:actions-by-tab tab-id]
(fnil conj #{}) action-id))))
(when-let [acc context/*registered-action-ids*]
(swap! acc conj action-id))
(let [action-data (cond-> {:fn action-fn
:session-id session-id
:tab-id tab-id}
(:as opts) (assoc :as (:as opts)))]
(swap! app-state* (fn [state]
(-> state
(assoc-in [:actions action-id] action-data)
(update-in [:actions-by-tab tab-id]
(fnil conj #{}) action-id))))
(when-let [acc context/*registered-action-ids*]
(swap! acc conj action-id))
(when-let [acc context/*rendered-actions*]
(swap! acc assoc action-id action-data)))
action-id))

;; ---------------------------------------------------------------------------
;; Render versions
;; ---------------------------------------------------------------------------
;; Action ids count the actions in a render, so the same id means different
;; actions in different renders. Each render that changes the page gets a
;; version, sent with the HTML (`data-hyper-render-version` on #hyper-app, or
;; on a reactive region's root for a partial render), and its actions are
;; kept under it. A click names the version of the HTML it was made on, so
;; it runs the action that HTML showed.
;;
;; Kept under [:tabs tab-id :renders]:
;; :versions {version {:actions {action-id action-data}}}
;; :last-version the latest version handed out
;; :page {:html html :version v} of the latest full render
;; :regions {region-id {:html html :version v}} of the latest partial renders

(def max-renders-per-tab
"The most render versions kept per tab; beyond it the oldest are dropped."
64)

(defn- trim-versions [{:keys [versions page] :as renders}]
(if (<= (count versions) max-renders-per-tab)
renders
(let [drop (->> (keys versions)
(remove #{(:version page)})
sort
(take (- (count versions) max-renders-per-tab)))]
(update renders :versions #(apply dissoc % drop)))))

(defn record-render!
"Store the `actions` a render registered under its version and return the
version: the version of the previous render of the same place (`region`, or
the page when nil) when it produced the same `html`, else a new one."
[app-state* tab-id {:keys [region html actions]}]
(let [k (if region [:regions region] [:page])
state (swap! app-state*
(fn [state]
(if-not (contains? (:tabs state) tab-id)
state
(update-in state [:tabs tab-id :renders]
(fn [renders]
(let [{last-html :html last-v :version} (get-in renders k)
same? (and (= html last-html)
(contains? (:versions renders) last-v))
v (if same? last-v (inc (:last-version renders 0)))]
(-> renders
(assoc :last-version (max v (:last-version renders 0)))
(assoc-in [:versions v :actions] actions)
(assoc-in k {:html html :version v})
trim-versions)))))))]
(get-in state (into [:tabs tab-id :renders] (conj k :version)))))

(defn evict-renders-before!
"Drop the render versions before `version`, the page version the client
has applied: a full render replaces everything rendered before it, so no
click can name them any more. The latest page version is always kept."
[app-state* tab-id version]
(when version
(swap! app-state*
(fn [state]
(if-not (contains? (:tabs state) tab-id)
state
(update-in state [:tabs tab-id :renders :versions]
(fn [versions]
(let [page-v (get-in state [:tabs tab-id :renders :page :version])]
(into {} (remove (fn [[v _]] (and (< v version) (not= v page-v))))
versions))))))))
nil)

(defn find-rendered-action
"The action `action-id` named in render `version` of tab `tab-id`, or in its
latest page render when version is nil. nil when that render is gone."
[app-state* tab-id version action-id]
(let [renders (get-in @app-state* [:tabs tab-id :renders])
version (or version (get-in renders [:page :version]))]
(get-in renders [:versions version :actions action-id])))

(defn execute-action!
"Execute an action by ID with error handling.
When client-params are provided they are passed to the action fn."
Expand Down
18 changes: 17 additions & 1 deletion src/hyper/client_params.clj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
switches the action's transport to a multipart/form-data upload (see
hyper.uploads): its `:js` must evaluate to a `FormData` object (the request
body), and its `:key` is the key under which the parsed upload is bound on
the server (\"form\" or \"files\").")
the server (\"form\" or \"files\")."
(:require [clojure.string :as str]))

(def ^:private default-client-param-registry
"The default registry of client params."
Expand Down Expand Up @@ -65,6 +66,21 @@
{:symbol symbol
:defined-symbols (defined-client-params)}))))

(defn- expression
"`js` as an expression. An `h/expr` with statements (see
`hyper.expr/join-statements`) returns its value through `__hyper_r`,
which only works as a whole handler, so it's wrapped in a function."
[js]
(if (and (string? js) (str/ends-with? js "return __hyper_r;"))
(str "(() => { " js " })()")
js))

(defn definition
"Client param `sym`'s definition, with its :js an expression that can sit
in an argument list, an object or another expression."
[sym]
(update (client-param sym) :js expression))

(defn multipart-param?
"True when `sym` is a client param whose transport is a multipart/form-data
upload (its definition carries `:multipart? true`) rather than the default
Expand Down
8 changes: 8 additions & 0 deletions src/hyper/context.clj
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@
;; in a single atomic swap — no cleanup-before-render gap needed.
(def ^:dynamic *registered-action-ids* nil)

;; The actions a render pass registered, as {action-id action-data}.
;; Bound to (atom {}) before each render; stored afterwards under the
;; render's version, so a click resolves its action id against the render
;; it was made on (see hyper.actions/record-render!).
(def ^:dynamic *rendered-actions* nil)

;; State overlay for cursor read/write indirection.
;;
;; When non-nil, a map with:
Expand Down Expand Up @@ -180,6 +186,7 @@
#'*region-path* []
#'*declared-signals* (atom [])
#'*registered-action-ids* (atom #{})
#'*rendered-actions* (atom {})
#'*registered-subview-ids* (atom #{})
#'*render-guard* (make-guard (get @app-state* :render-guard :warn))
#'*state-overlay* {:state* (atom @app-state*)
Expand All @@ -195,6 +202,7 @@
#'*region-path* []
#'*declared-signals* (atom [])
#'*registered-action-ids* (atom #{})
#'*rendered-actions* (atom {})
#'*registered-subview-ids* (atom #{})
;; Partial reactive renders have no setup phase — the body is a pure
;; render fn, so the guard is active immediately.
Expand Down
5 changes: 2 additions & 3 deletions src/hyper/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -703,7 +703,7 @@
(->> (client-params/defined-client-params)
(filter all-syms)
(reduce (fn [m sym]
(assoc m sym (client-params/client-param sym)))
(assoc m sym (client-params/definition sym)))
{}))))

(defmacro ^{:hyper/datastar-expr true} action
Expand Down Expand Up @@ -912,7 +912,6 @@
tab-id (:hyper/tab-id *request*)]
(when-let [route (routes/tab-route router route-name params query-params)]
(let [href (routes/route-url router route)
base-path (get @app-state* :base-path "")
;; Use live-routes to always get the latest route metadata
route-index (routes/live-route-index app-state*)
;; Resolve title eagerly for the pushState call
Expand All @@ -935,7 +934,7 @@
{:href href
:data-on:click__prevent
(str "window.hyper.startNavigation('" escaped-href "');"
" @post('" base-path "/hyper/actions?action-id=" action-id "');"
" @hyperAction('" action-id "');"
" window.history.pushState({title: '" escaped-title "'}, '', '" escaped-href "');"
(when title
(str " document.title = '" escaped-title "'")))})))))
Expand Down
2 changes: 1 addition & 1 deletion src/hyper/expr.clj
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
(walk/postwalk
(fn [node]
(if (and (symbol? node) (contains? params node))
(list 'js* (:js (client-params/client-param node)))
(list 'js* (:js (client-params/definition node)))
node))
form)))

Expand Down
9 changes: 8 additions & 1 deletion src/hyper/render.clj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Handles rendering hiccup to HTML and formatting Datastar SSE events."
(:require [clojure.string :as string]
[dev.onionpancakes.chassis.core :as c]
[hyper.actions :as actions]
[hyper.component.bundle :as component.bundle]
[hyper.context :as context]
[hyper.lifecycle :as lifecycle]
Expand Down Expand Up @@ -291,6 +292,8 @@
:body-html - HTML string of the rendered page body
:url - current route URL string, or nil
:declared-signals - vector of signal declaration maps for HTML injection
:render-version - the version the render's actions are kept under
(see hyper.actions/record-render!)

Binds `context/*request*` and `context/*action-idx*` for the duration
of both rendering and HTML serialization, so lazy hiccup sequences
Expand Down Expand Up @@ -395,7 +398,11 @@
:url url
:declared-signals declared
:registered-action-ids action-ids
:registered-subview-ids subview-ids})))
:registered-subview-ids subview-ids
:render-version (actions/record-render!
app-state* tab-id
{:html body-html
:actions @context/*rendered-actions*})})))
(finally
(pop-thread-bindings)))))))

Expand Down
Loading