Skip to content

Commit 613b7b6

Browse files
committed
wip
1 parent 07f3f46 commit 613b7b6

File tree

6 files changed

+231
-130
lines changed

6 files changed

+231
-130
lines changed

assets/js/phoenix_live_view/view.js

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -418,8 +418,7 @@ export default class View {
418418
this.maybeAddNewHook(hookEl)
419419
}
420420
})
421-
let phxHook = this.binding(PHX_HOOK)
422-
DOM.all(this.el, `[${phxHook}], [data-phx-${PHX_HOOK}], script[type="${phxHook}"]`, hookEl => {
421+
DOM.all(parent, `[${this.binding(PHX_HOOK)}], [data-phx-${PHX_HOOK}]`, hookEl => {
423422
if(this.ownsElement(hookEl)){
424423
this.maybeAddNewHook(hookEl)
425424
}
@@ -731,12 +730,7 @@ export default class View {
731730
return
732731
} else {
733732
// new hook found with phx-hook attribute
734-
let hookName
735-
if(el.type === "text/phx-hook"){
736-
hookName = el.innerText
737-
} else {
738-
hookName = el.getAttribute(`data-phx-${PHX_HOOK}`) || el.getAttribute(this.binding(PHX_HOOK))
739-
}
733+
let hookName = el.getAttribute(`data-phx-${PHX_HOOK}`) || el.getAttribute(this.binding(PHX_HOOK))
740734
let callbacks = this.liveSocket.getHookCallbacks(hookName)
741735

742736
if(callbacks){
@@ -745,7 +739,24 @@ export default class View {
745739
this.viewHooks[ViewHook.elementID(hook.el)] = hook
746740
return hook
747741
} else if(hookName !== null){
748-
logError(`unknown hook found for "${hookName}"`, el)
742+
// TODO: this is ugly and evaluating the script content is probably not what we want
743+
// e.g. for LiveDashboard a regular <script> with type javascript and nonce would be better
744+
// so maybe switch away from type="text/phx-hook" and use another attribute to identify hooks
745+
const runtimeHook = document.querySelector(`script[type="text/phx-hook"][name="${CSS.escape(hookName)}"][bundle="runtime"]`)
746+
if (runtimeHook) {
747+
// if you really want runtime hooks, I
748+
callbacks = new Function(runtimeHook.textContent)()
749+
if (callbacks && typeof callbacks === "object") {
750+
if(!el.id){ logError(`no DOM ID for hook "${hookName}". Hooks require a unique ID on each element.`, el) }
751+
let hook = new ViewHook(this, el, callbacks)
752+
this.viewHooks[ViewHook.elementID(hook.el)] = hook
753+
return hook
754+
} else {
755+
logError("runtime hook must return an object with hook callbacks", runtimeHook)
756+
}
757+
} else {
758+
logError(`unknown hook found for "${hookName}"`, el)
759+
}
749760
}
750761
}
751762
}

assets/js/phoenix_live_view/view_hook.js

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,7 @@ export default class ViewHook {
1515
this.__listeners = new Set()
1616
this.__isDisconnected = false
1717
DOM.putPrivate(this.el, HOOK_ID, this.constructor.makeID())
18-
if(typeof(callbacks) === "function"){
19-
this.updated = () => {
20-
return function(){}
21-
}
22-
23-
this.mounted = () => callbacks({
24-
el: el,
25-
pushEvent: this.pushEvent.bind(this),
26-
pushEventTo: this.pushEventTo.bind(this),
27-
handleEvent: this.handleEvent.bind(this),
28-
removeHandleEvent: this.removeHandleEvent.bind(this),
29-
upload: this.upload.bind(this),
30-
uploadTo: this.uploadTo.bind(this),
31-
updated: (cb) => this.updated = cb,
32-
disconnected: (cb) => this.disconnected = cb,
33-
})
34-
} else {
35-
for(let key in this.__callbacks){ this[key] = this.__callbacks[key] }
36-
}
18+
for(let key in this.__callbacks){ this[key] = this.__callbacks[key] }
3719
}
3820

3921
__attachView(view){

lib/phoenix_component.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1787,7 +1787,7 @@ defmodule Phoenix.Component do
17871787

17881788
imports =
17891789
quote bind_quoted: [opts: opts] do
1790-
Phoenix.LiveView.Tokenizer.prune(__ENV__.file)
1790+
Phoenix.LiveView.HTMLEngine.prune_hooks(__ENV__.file)
17911791
import Kernel, except: [def: 2, defp: 2]
17921792
import Phoenix.Component
17931793
import Phoenix.Component.Declarative

lib/phoenix_live_view/html_engine.ex

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,4 +244,198 @@ defmodule Phoenix.LiveView.HTMLEngine do
244244
defp current_otp_app do
245245
Application.get_env(:logger, :compile_time_application)
246246
end
247+
248+
@impl true
249+
def token_preprocess(tokens, opts) do
250+
file = Keyword.fetch!(opts, :file)
251+
caller = Keyword.fetch!(opts, :caller)
252+
module = caller.module
253+
254+
{hooks, tokens, _module} = process_hooks(tokens, {%{}, [], module})
255+
hooks = write_hooks_and_manifest(hooks, file)
256+
257+
if hooks == %{} do
258+
Enum.reverse(tokens)
259+
else
260+
# when a <script type="text/phx-hook" name="..." > is found, we generate the hook name
261+
# based on its content. Then, we need to rewrite the phx-hook="..." attribute of all
262+
# other tags to match the generated hook name.
263+
# This is expensive, as we traverse all tags and attributes,
264+
# but we only do it if a script hook is present.
265+
rewrite_hook_names(hooks, tokens)
266+
end
267+
end
268+
269+
defp process_hooks(
270+
[
271+
{:tag, "script", attrs, meta} = start,
272+
{:text, text, _} = content,
273+
{:close, :tag, "script", _} = end_ | rest
274+
],
275+
{hooks, tokens_acc, module}
276+
) do
277+
str_attrs = for {name, {:string, value, _}, _} <- attrs, into: %{}, do: {name, value}
278+
279+
case str_attrs do
280+
# keep runtime hooks
281+
%{"type" => "text/phx-hook", "name" => name, "bundle" => "runtime"} ->
282+
# keep bundle="runtime" hooks in DOM
283+
# TODO: handle in JS
284+
process_hooks(rest, {hooks, [end_, content, start | tokens_acc], module})
285+
286+
%{"type" => "text/phx-hook", "name" => name, "bundle" => "current_otp_app"} ->
287+
# only consider bundle="current_otp_app" hooks if they are part of the current otp_app
288+
if current_otp_app() == Application.get_application(module) do
289+
hooks = Map.put(hooks, name, %{content: text, attrs: str_attrs, meta: meta})
290+
process_hooks(rest, {hooks, [end_, content, start | tokens_acc], module})
291+
else
292+
process_hooks(rest, {hooks, tokens_acc, module})
293+
end
294+
295+
%{"type" => "text/phx-hook", "name" => name} ->
296+
# by default, hooks with no hook-type are extracted, no matter where they're from
297+
hooks = Map.put(hooks, name, %{content: text, attrs: str_attrs, meta: meta})
298+
process_hooks(rest, {hooks, tokens_acc, module})
299+
300+
%{"type" => "text/phx-hook"} ->
301+
# TODO: nice error message
302+
raise ArgumentError,
303+
"scripts with type=\"text/phx-hook\" must have a compile-time string \"name\" attribute"
304+
305+
_ ->
306+
process_hooks(rest, {hooks, [end_, content, start | tokens_acc], module})
307+
end
308+
end
309+
310+
defp process_hooks([{:tag, "script", attrs, _meta} = start | rest], {hooks, tokens_acc, module}) do
311+
if Enum.find(attrs, match?({"type", {:string, "text/phx-hook", _}, _}, attrs)) do
312+
# TODO: nice error message
313+
raise ArgumentError,
314+
"scripts with type=\"text/phx-hook\" must not contain any interpolation!"
315+
else
316+
process_hooks(rest, {hooks, [start | tokens_acc], module})
317+
end
318+
end
319+
320+
defp process_hooks([token | rest], {hooks, tokens_acc, module}),
321+
do: process_hooks(rest, {hooks, [token | tokens_acc], module})
322+
323+
defp process_hooks([], acc), do: acc
324+
325+
defp rewrite_hook_names(hooks, tokens) do
326+
for token <- tokens, reduce: [] do
327+
acc ->
328+
case token do
329+
{:tag, name, attrs, meta} ->
330+
[{:tag, name, rewrite_hook_attrs(hooks, attrs), meta} | acc]
331+
332+
{:local_component, name, attrs, meta} ->
333+
[{:local_component, name, rewrite_hook_attrs(hooks, attrs), meta} | acc]
334+
335+
{:remote_component, name, attrs, meta} ->
336+
[{:remote_component, name, rewrite_hook_attrs(hooks, attrs), meta} | acc]
337+
338+
other ->
339+
[other | acc]
340+
end
341+
end
342+
end
343+
344+
defp rewrite_hook_attrs(hooks, attrs) do
345+
Enum.map(attrs, fn
346+
{"phx-hook", {:string, name, meta1}, meta2} ->
347+
if is_map_key(hooks, name) do
348+
{"phx-hook", {:string, hooks[name].name, meta1}, meta2}
349+
else
350+
{"phx-hook", {:string, name, meta1}, meta2}
351+
end
352+
353+
{attr, value, meta} ->
354+
{attr, value, meta}
355+
end)
356+
end
357+
358+
defp write_hooks_and_manifest(hooks, file) do
359+
for {name, %{content: raw_content, attrs: attrs, meta: meta} = hook} <- hooks,
360+
attrs["hook-type"] == nil or attrs["hook-type"] == "default",
361+
into: %{} do
362+
line = meta[:line]
363+
col = meta[:column]
364+
365+
script_content =
366+
"// #{Path.relative_to_cwd(file)}:#{line}:#{col}\n" <> raw_content
367+
368+
dir = "assets/js/hooks"
369+
manifest_path = Path.join(dir, "index.js")
370+
371+
js_filename = hashed_script_name(file) <> "_#{line}_#{col}"
372+
js_path = Path.join(dir, js_filename <> ".js")
373+
374+
File.mkdir_p!(dir)
375+
File.write!(js_path, script_content)
376+
377+
if !File.exists?(manifest_path) do
378+
File.write!(manifest_path, """
379+
let hooks = {}
380+
export default hooks
381+
""")
382+
end
383+
384+
manifest = File.read!(manifest_path)
385+
386+
File.open(manifest_path, [:append], fn file ->
387+
if !String.contains?(manifest, js_filename) do
388+
IO.puts("Add hook to #{manifest_path}")
389+
390+
IO.binwrite(
391+
file,
392+
~s|\nimport hook_#{js_filename} from "./#{js_filename}"; hooks["#{js_filename}"] = hook_#{js_filename};|
393+
)
394+
end
395+
end)
396+
397+
IO.puts("Write hook to #{js_path}")
398+
399+
{name, Map.put(hook, :name, js_filename)}
400+
end
401+
end
402+
403+
defp hashed_script_name(file) do
404+
:md5 |> :crypto.hash(file) |> Base.encode16()
405+
end
406+
407+
def prune_hooks(file) do
408+
hashed_name = hashed_script_name(file)
409+
hooks_dir = Path.expand("assets/js/hooks", File.cwd!())
410+
manifest_path = Path.join(hooks_dir, "index.js")
411+
412+
case File.ls(hooks_dir) do
413+
{:ok, hooks} ->
414+
for hook_basename <- hooks do
415+
case String.split(hook_basename, "_") do
416+
[^hashed_name | _] ->
417+
File.rm!(IO.inspect(Path.join(hooks_dir, hook_basename), label: "Pruning"))
418+
419+
if File.exists?(manifest_path) do
420+
new_file =
421+
manifest_path
422+
|> File.stream!()
423+
|> Enum.filter(fn line -> !String.contains?(line, hashed_name) end)
424+
|> Enum.join("")
425+
|> String.trim()
426+
427+
File.write!(manifest_path, new_file)
428+
end
429+
430+
_ ->
431+
:noop
432+
end
433+
end
434+
435+
_ ->
436+
:noop
437+
end
438+
439+
nil
440+
end
247441
end

lib/phoenix_live_view/tag_engine.ex

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ defmodule Phoenix.LiveView.TagEngine do
6363
"""
6464
@callback annotate_caller(file :: String.t(), line :: integer()) :: String.t() | nil
6565

66+
@doc """
67+
Callback invoked to preprocess raw tokens.
68+
"""
69+
@callback token_preprocess(tokens :: list(), opts :: keyword()) :: list()
70+
6671
@doc """
6772
Renders a component defined by the given function.
6873
@@ -203,6 +208,13 @@ defmodule Phoenix.LiveView.TagEngine do
203208
%{tokens: tokens, file: file, cont: cont, source: source, caller: caller} = state
204209
tokens = Tokenizer.finalize(tokens, file, cont, source)
205210

211+
tokens =
212+
if function_exported?(state.tag_handler, :token_preprocess, 2) do
213+
state.tag_handler.token_preprocess(tokens, file: file, caller: caller)
214+
else
215+
tokens
216+
end
217+
206218
token_state =
207219
state
208220
|> token_state(nil)

0 commit comments

Comments
 (0)