phlex-reactive 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +213 -0
  3. data/README.md +333 -1
  4. data/app/controllers/phlex/reactive/actions_controller.rb +120 -10
  5. data/app/javascript/phlex/reactive/inspect.js +225 -0
  6. data/app/javascript/phlex/reactive/inspect.min.js +4 -0
  7. data/app/javascript/phlex/reactive/inspect.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/reactive_controller.js +651 -2
  9. data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
  10. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
  11. data/lib/generators/phlex/reactive/claude/USAGE +15 -0
  12. data/lib/generators/phlex/reactive/claude/claude_generator.rb +86 -0
  13. data/lib/generators/phlex/reactive/install/install_generator.rb +3 -0
  14. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +56 -0
  15. data/lib/phlex/reactive/authorization.rb +118 -0
  16. data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +105 -0
  17. data/lib/phlex/reactive/component/dsl.rb +70 -0
  18. data/lib/phlex/reactive/component/helpers.rb +240 -0
  19. data/lib/phlex/reactive/component/identity.rb +10 -1
  20. data/lib/phlex/reactive/component/lazy.rb +79 -0
  21. data/lib/phlex/reactive/component/registry.rb +7 -1
  22. data/lib/phlex/reactive/component.rb +1 -0
  23. data/lib/phlex/reactive/defer.rb +363 -0
  24. data/lib/phlex/reactive/deferred_render_job.rb +116 -0
  25. data/lib/phlex/reactive/doctor.rb +79 -11
  26. data/lib/phlex/reactive/engine.rb +16 -2
  27. data/lib/phlex/reactive/inspector/report.rb +140 -0
  28. data/lib/phlex/reactive/inspector.rb +253 -0
  29. data/lib/phlex/reactive/log_subscriber.rb +10 -0
  30. data/lib/phlex/reactive/mcp/base_tool.rb +57 -0
  31. data/lib/phlex/reactive/mcp/runner.rb +24 -0
  32. data/lib/phlex/reactive/mcp/server.rb +47 -0
  33. data/lib/phlex/reactive/mcp/tools/actions_tool.rb +43 -0
  34. data/lib/phlex/reactive/mcp/tools/components_tool.rb +39 -0
  35. data/lib/phlex/reactive/mcp/tools/config_tool.rb +74 -0
  36. data/lib/phlex/reactive/mcp/tools/doctor_tool.rb +36 -0
  37. data/lib/phlex/reactive/mcp/tools/find_tool.rb +66 -0
  38. data/lib/phlex/reactive/mcp.rb +58 -0
  39. data/lib/phlex/reactive/reply.rb +18 -0
  40. data/lib/phlex/reactive/response.rb +47 -2
  41. data/lib/phlex/reactive/streamable.rb +5 -1
  42. data/lib/phlex/reactive/version.rb +1 -1
  43. data/lib/phlex/reactive.rb +253 -3
  44. data/lib/tasks/phlex_reactive.rake +28 -0
  45. metadata +22 -1
@@ -0,0 +1,15 @@
1
+ Description:
2
+ Installs the phlex-reactive debugging toolkit for Claude Code:
3
+ copies the `phlex-reactive-debugging` skill into .claude/skills/ (the
4
+ debugging runbook — doctor → inventory → find → browser scan → MCP) and adds
5
+ the read-only diagnostic MCP server to .mcp.json (only when absent; it never
6
+ rewrites an existing .mcp.json — it prints the snippet instead).
7
+
8
+ The MCP server needs the optional `mcp` gem (`gem "mcp"`); the skill and rake
9
+ tasks work without it.
10
+
11
+ Example:
12
+ bin/rails generate phlex:reactive:claude
13
+
14
+ Creates .claude/skills/phlex-reactive-debugging/ and, when absent, .mcp.json
15
+ with the phlex-reactive server entry.
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "json"
5
+
6
+ module Phlex
7
+ module Reactive
8
+ module Generators
9
+ # `rails g phlex:reactive:claude`
10
+ #
11
+ # Installs the phlex-reactive debugging toolkit for Claude Code inside a
12
+ # host app (issue #168):
13
+ # * copies the shipped `phlex-reactive-debugging` skill into
14
+ # .claude/skills/ (the debugging runbook: doctor → inventory → find →
15
+ # browser scan → MCP)
16
+ # * adds the read-only MCP server to .claude/../.mcp.json — but ONLY when
17
+ # the file is absent (never rewrite an existing .mcp.json); otherwise it
18
+ # prints the snippet to add by hand.
19
+ #
20
+ # The skill ships INSIDE the gem (under lib/, so the gemspec packages it),
21
+ # not as a template, so it always matches the installed gem version.
22
+ class ClaudeGenerator < ::Rails::Generators::Base
23
+ SKILL_NAME = "phlex-reactive-debugging"
24
+
25
+ # The shipped skills live inside the gem under lib/phlex/reactive/claude/
26
+ # skills/ (packaged by the gemspec). source_root points there so
27
+ # `directory` copies the skill tree by its relative name.
28
+ source_root File.expand_path("../../../../phlex/reactive/claude/skills", __dir__)
29
+
30
+ # The MCP server entry a host app adds to .mcp.json. Introspection needs
31
+ # the booted app, so the command is the rake task (not a standalone exe).
32
+ MCP_ENTRY = {
33
+ "mcpServers" => {
34
+ "phlex-reactive" => {
35
+ "command" => "bin/rails",
36
+ "args" => ["phlex_reactive:mcp"]
37
+ }
38
+ }
39
+ }.freeze
40
+
41
+ # The shipped skill directory inside the gem (packaged under lib/).
42
+ def self.skill_source_dir
43
+ File.expand_path("../../../../phlex/reactive/claude/skills/#{SKILL_NAME}", __dir__)
44
+ end
45
+
46
+ # Copy the shipped skill into the host app's .claude/skills/.
47
+ def install_skill
48
+ source = self.class.skill_source_dir
49
+ dest = File.join(".claude", "skills", SKILL_NAME)
50
+ directory(source, dest)
51
+ end
52
+
53
+ # Create .mcp.json with the server entry when it's absent; otherwise print
54
+ # the snippet (NEVER rewrite an existing .mcp.json — it may hold other
55
+ # servers the app configured).
56
+ def configure_mcp
57
+ path = ".mcp.json"
58
+ if File.exist?(File.join(destination_root, path))
59
+ say_status :skip, "#{path} exists — add the phlex-reactive server yourself:", :yellow
60
+ say(pretty_entry)
61
+ else
62
+ create_file path, "#{pretty_entry}\n"
63
+ end
64
+ end
65
+
66
+ def show_post_install
67
+ say_status :info, "phlex-reactive debugging toolkit installed for Claude Code.", :green
68
+ say <<~MSG
69
+
70
+ The `#{SKILL_NAME}` skill teaches the debugging workflow (doctor →
71
+ inventory → find → browser report() → MCP). The MCP server needs the
72
+ optional `mcp` gem — add `gem "mcp"` to your Gemfile to enable it.
73
+
74
+ Try it: bin/rails phlex_reactive:doctor
75
+ MSG
76
+ end
77
+
78
+ private
79
+
80
+ def pretty_entry
81
+ JSON.pretty_generate(MCP_ENTRY)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -61,6 +61,9 @@ module Phlex
61
61
  Scaffold one with: rails g phlex:reactive:component Counter
62
62
 
63
63
  Then verify the whole install: bin/rails phlex_reactive:doctor
64
+
65
+ Debugging with Claude Code? Install the toolkit (skill + MCP server):
66
+ rails g phlex:reactive:claude
64
67
  MSG
65
68
  end
66
69
 
@@ -15,6 +15,16 @@
15
15
  # Phlex::Reactive.authorization_errors = [Pundit::NotAuthorizedError]
16
16
  # Phlex::Reactive.authorization_errors = [ActionPolicy::Unauthorized]
17
17
 
18
+ # verify_authorized (ON by default): an action that completes WITHOUT any
19
+ # authorization call raises Phlex::Reactive::AuthorizationNotVerified inside the
20
+ # transaction — the mutation rolls back (fail-closed). Satisfy it by calling one
21
+ # of authorization_methods, calling `mark_authorized!` after a bespoke check, or
22
+ # declaring `skip_verify_authorized` on a genuinely public component/action.
23
+ # Set the method names to match your authorization library:
24
+ # Phlex::Reactive.authorization_methods = %i[authorize! authorize allowed_to?]
25
+ # Turn the guard off entirely (not recommended — you lose the fail-closed net):
26
+ # Phlex::Reactive.verify_authorized = false
27
+
18
28
  # Diagnostic endpoint error bodies + dropped-param logging (statuses never
19
29
  # change). Defaults to Rails.env.local? — on in development AND test, off in production.
20
30
  # Phlex::Reactive.verbose_errors = true
@@ -41,6 +51,52 @@
41
51
  # <meta name="phlex-reactive-action-path" content="<%%= Phlex::Reactive.action_path %>">
42
52
  # Phlex::Reactive.action_path = "/_r/actions"
43
53
 
54
+ # --- Deferred reply segments (reply.defer + reactive_lazy, issue #165) ------
55
+ # PROFILE FIRST: an app-side N+1 looks exactly like framework lag — make the
56
+ # synchronous path cheap before deferring a segment.
57
+ #
58
+ # How the deferred render reaches the actor (default :auto — a durable pgbus
59
+ # one-shot stream + ActiveJob when both are available, else a parallel fetch to
60
+ # the defer endpoint; :fetch forces the fetch lane, :stream requests push and
61
+ # degrades to fetch with a warning when the capability is absent):
62
+ # Phlex::Reactive.defer_transport = :auto
63
+ #
64
+ # Defer tokens are purpose-scoped and short-lived — the TTL only needs to cover
65
+ # the reply→fetch gap (default 120 seconds):
66
+ # Phlex::Reactive.defer_token_ttl = 120
67
+ #
68
+ # reply.defer tokens are ACTOR-BOUND: signed under the requesting session so a
69
+ # leaked token can't be redeemed in another session. The default binding is the
70
+ # session id; override to bind to your own actor identity (a user id, an API
71
+ # token digest). Apps with no session middleware mint UNBOUND tokens — their
72
+ # bound is then the TTL plus any authorization your component enforces AT RENDER
73
+ # TIME. Note the defer endpoint runs NO action and calls NO authorize! itself
74
+ # (it's a read); protection comes from the component raising a registered
75
+ # authorization error from from_identity/render, or returning false from
76
+ # render?, when it's rebuilt at the defer endpoint.
77
+ # Phlex::Reactive.singleton_class.class_eval do
78
+ # def defer_binding_for(request) = Current.user&.id&.to_s
79
+ # end
80
+ #
81
+ # The push lane's render job queue (default "default"). Point it at a FAST
82
+ # queue in production — a deferred segment is a UX-latency render; letting it
83
+ # starve behind heavy jobs defeats the point:
84
+ # Phlex::Reactive.defer_job_queue = "latency"
85
+ #
86
+ # PUSH LANE QUEUE LIFECYCLE: each deferred segment on the push lane mints a
87
+ # durable one-shot pgbus stream. Its queue is reclaimed by pgbus's age-based
88
+ # orphan-stream sweep (pgbus >= 0.9.10) — make sure the pgbus Dispatcher runs
89
+ # with streams_orphan_threshold set. We never drop it from the job (an eager
90
+ # drop would destroy an unconsumed message and reopen the broadcast-before-
91
+ # subscribe race). On pgbus <= 0.9.9, or if you don't run the reaper, force the
92
+ # (universal, cleanup-free) fetch lane:
93
+ # Phlex::Reactive.defer_transport = :fetch
94
+ #
95
+ # Change the defer endpoint path (default "/reactive/defer"). If you change it,
96
+ # expose it to the client with:
97
+ # <meta name="phlex-reactive-defer-path" content="<%%= Phlex::Reactive.defer_path %>">
98
+ # Phlex::Reactive.defer_path = "/_r/defer"
99
+
44
100
  # Client request timeout (default 30s). There is NO server-side setting — a hung
45
101
  # request aborts client-side after this window (reactive:error kind "timeout"),
46
102
  # so the per-component queue never wedges. Override in your layout head:
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The runtime machinery behind verify_authorized (issue #168): a time-window
6
+ # tracking cell, an interceptor that marks it when a configured authorization
7
+ # method returns, and the enforcement decision the endpoint calls after an
8
+ # action runs.
9
+ #
10
+ # The design mirrors Phlex::Reactive.with_connection_id: a fiber-local cell
11
+ # (Thread.current, which is fiber-local in Ruby, so Falcon's fiber-per-request
12
+ # model is safe) saved and restored around the action. Because tracking is a
13
+ # WINDOW — anything marking during the action counts — authorization performed
14
+ # in a HELPER the action calls is detected too, not just a top-level call.
15
+ module Authorization
16
+ # The fiber-local key holding the tracking cell during an action. A truthy
17
+ # value means "some authorization method has been called (and returned)
18
+ # during this window".
19
+ CELL = :phlex_reactive_authorized
20
+
21
+ # Streamable defines a CLASS method `prepend(target:, model:)` (the Turbo
22
+ # Stream verb), which shadows Ruby's Module#prepend on a component class.
23
+ # Bind the real Module#prepend so instrument! can prepend the interceptor
24
+ # module without dispatching to the stream helper (and its keyword arity).
25
+ MODULE_PREPEND = ::Module.instance_method(:prepend)
26
+ private_constant :MODULE_PREPEND
27
+
28
+ class << self
29
+ # Open a fresh tracking window, run the block, and restore the previous
30
+ # cell — exactly the with_connection_id save/restore shape, so nesting is
31
+ # correct and a raise still restores. The inner window starts UNMARKED
32
+ # regardless of an outer mark (a fresh action authorizes for itself).
33
+ def with_tracking
34
+ previous = Thread.current[CELL]
35
+ Thread.current[CELL] = false
36
+ yield
37
+ ensure
38
+ Thread.current[CELL] = previous
39
+ end
40
+
41
+ # Mark the current window authorized. Called by the interceptor (on a
42
+ # non-raising authorization-method return) and by Component#mark_authorized!
43
+ # for a bespoke check. A no-op semantically outside a window (the cell is
44
+ # simply overwritten and restored by the nearest with_tracking).
45
+ def mark!
46
+ Thread.current[CELL] = true
47
+ end
48
+
49
+ # Has anything marked the current window?
50
+ def marked?
51
+ Thread.current[CELL] == true
52
+ end
53
+
54
+ # The enforcement decision, called by the endpoint AFTER the action runs,
55
+ # INSIDE the transaction (so a raise rolls the mutation back). A no-op
56
+ # unless verify_authorized is on, the action isn't skipped, and nothing
57
+ # marked the window. Otherwise raises AuthorizationNotVerified naming the
58
+ # component#action and the three remedies.
59
+ def verify!(component_class, action_def)
60
+ return unless Phlex::Reactive.verify_authorized
61
+ return if skipped?(component_class, action_def.name)
62
+ return if marked?
63
+
64
+ raise Phlex::Reactive::AuthorizationNotVerified, violation_message(component_class, action_def)
65
+ end
66
+
67
+ # Wrap each configured authorization method the class defines (public OR
68
+ # private, own OR inherited) with a prepended module that calls super and
69
+ # then mark! on a NON-RAISING return — so a denial (which raises) never
70
+ # marks and still propagates to the endpoint's authorization_errors → 403.
71
+ #
72
+ # Idempotent per class OBJECT (memoized on an ivar the class carries), so
73
+ # a Zeitwerk reload's fresh class re-instruments naturally and a repeated
74
+ # call in one process is a no-op. Re-reads authorization_methods each first
75
+ # call, so an initializer that widened the list is honored.
76
+ def instrument!(component_class)
77
+ return if component_class.instance_variable_get(:@reactive_authorization_instrumented)
78
+
79
+ names = Phlex::Reactive.authorization_methods.select do
80
+ component_class.method_defined?(it) || component_class.private_method_defined?(it)
81
+ end
82
+ MODULE_PREPEND.bind_call(component_class, interceptor_for(names)) if names.any?
83
+ component_class.instance_variable_set(:@reactive_authorization_instrumented, true)
84
+ end
85
+
86
+ private
87
+
88
+ def skipped?(component_class, action_name)
89
+ component_class.respond_to?(:skip_verify_authorized?) &&
90
+ component_class.skip_verify_authorized?(action_name)
91
+ end
92
+
93
+ # Build the prepend module: one wrapper per authorization method name,
94
+ # each `super(...)` then mark! (only reached when super didn't raise).
95
+ def interceptor_for(names)
96
+ Module.new do
97
+ names.each do
98
+ define_method(it) do |*args, **kwargs, &block|
99
+ result = super(*args, **kwargs, &block)
100
+ Phlex::Reactive::Authorization.mark!
101
+ result
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ def violation_message(component_class, action_def)
108
+ where = [component_class&.name, action_def&.name].compact.join("#")
109
+ methods = Phlex::Reactive.authorization_methods.map { ":#{it}" }.join(", ")
110
+ "#{where} completed without any authorization call — verify_authorized is on. " \
111
+ "Either call one of your authorization methods (#{methods}), call mark_authorized! " \
112
+ "for a bespoke check, or declare `skip_verify_authorized` (whole component) / " \
113
+ "`skip_verify_authorized :#{action_def&.name}` (this action) if it is intentionally public."
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,105 @@
1
+ ---
2
+ name: phlex-reactive-debugging
3
+ description: "Debug phlex-reactive in a Rails app: a reactive component that doesn't update, a 400/403/404 from POST /reactive/actions, an AuthorizationNotVerified error, or an audit of which reactive actions authorize. Use when a reactive click does nothing, a Turbo Stream never arrives, or you need to inventory the reactive components/actions in the app."
4
+ ---
5
+
6
+ # Debugging phlex-reactive
7
+
8
+ phlex-reactive turns every reactive component into a browser-reachable RPC:
9
+ `POST /reactive/actions` with a signed identity token → verify → rebuild the
10
+ component → run the declared action → render an auto-targeted Turbo Stream. When
11
+ something "just doesn't work," the failure is almost always at one of those
12
+ seams. This skill is the runbook.
13
+
14
+ Work top-down: **validate the install → inventory the server → find the specific
15
+ component → scan the live page → introspect the running app.** Stop as soon as a
16
+ step names the problem.
17
+
18
+ ## 1. Validate the install (`doctor`)
19
+
20
+ ```bash
21
+ bin/rails phlex_reactive:doctor
22
+ ```
23
+
24
+ Prints ✓/✗/? for the whole install: does `POST /reactive/actions` route to the
25
+ gem (a host catch-all route shadows it otherwise), is the generic `reactive`
26
+ Stimulus controller registered, does the identity verifier round-trip, does every
27
+ declared `action :name` have a public method, does every component resolve a
28
+ stable `#id`, and an **advisory** authorization heuristic. A ✗ prints the fix.
29
+ Run this FIRST — most "nothing happens" reports are a red ✗ here.
30
+
31
+ ## 2. Inventory the server (`actions` / `find`)
32
+
33
+ ```bash
34
+ bin/rails phlex_reactive:actions # every component × action: params, file:line, auth
35
+ bin/rails phlex_reactive:actions FORMAT=json
36
+ bin/rails "phlex_reactive:find[counter]" # fuzzy-find one; prints each action's method source
37
+ ```
38
+
39
+ Answers "what reactive actions exist, where are they defined, and is each
40
+ authorized?" without grepping. The `auth` column is a heuristic (`authorized*` =
41
+ an authorization call was detected in the body; `unverified` = none — a helper
42
+ may still authorize indirectly).
43
+
44
+ ## 3. Scan the live page (browser inspector)
45
+
46
+ In the browser console on the affected page:
47
+
48
+ ```js
49
+ (await import("phlex/reactive/inspect")).report()
50
+ ```
51
+
52
+ `report()` prints a `console.table` of every reactive root on the page — its
53
+ `id`, decoded token payload (**component class**, gid, state keys), status
54
+ (error/busy/dirty), the bound **triggers** (action + event + params +
55
+ debounce/confirm), client-only ops, computes, and the named fields a dispatch
56
+ would send. `scan()` returns the same data.
57
+
58
+ **The server↔client mapping is by name.** The `component` and each trigger's
59
+ `action` in `report()` are exactly the identifiers `phlex_reactive:actions`
60
+ lists. So: does the button on the page carry the action you expect? Does its
61
+ `component` match the class you think renders here? A mismatch (or a root with an
62
+ `(opaque token)`) localizes the bug.
63
+
64
+ ## 4. Introspect the running app (MCP server)
65
+
66
+ For live-app introspection from inside your editor, run the read-only MCP server
67
+ (needs the optional `mcp` gem — `gem "mcp"`):
68
+
69
+ ```json
70
+ // .mcp.json
71
+ { "mcpServers": { "phlex-reactive": { "command": "bin/rails", "args": ["phlex_reactive:mcp"] } } }
72
+ ```
73
+
74
+ It exposes five read-only tools — `phlex_reactive_doctor`,
75
+ `phlex_reactive_components`, `phlex_reactive_actions` (optional `component:`
76
+ filter), `phlex_reactive_find`, `phlex_reactive_config` — the same data as the
77
+ rake tasks, queryable in conversation. Constraint: stdio MCP needs a clean
78
+ stdout, so an initializer that `puts` breaks it.
79
+
80
+ `rails g phlex:reactive:claude` installs this skill and writes the `.mcp.json`
81
+ entry for you.
82
+
83
+ ## Failure table
84
+
85
+ | Symptom | Cause | Fix |
86
+ |---------|-------|-----|
87
+ | **400** (bad request) | Tampered / stale token — a token from before a deploy, a `secret_key_base` mismatch, or a `reply.with(...)` stream that skipped the token refresh | Reload the page for a fresh token; confirm `secret_key_base`; ensure custom streams refresh the token |
88
+ | **403** (forbidden) | The action isn't declared (`action :name`), OR your authorizer raised and is registered in `Phlex::Reactive.authorization_errors` | Declare the action; or the 403 is correct — the user isn't allowed |
89
+ | **`AuthorizationNotVerified`** (500) | `verify_authorized` is ON and the action completed without calling an authorization method or `mark_authorized!` | Call your authorization method inside the action, call `mark_authorized!` after a bespoke check, or declare `skip_verify_authorized` if it's genuinely public |
90
+ | **404** (not found) | The record was deleted while a page still showed it (record-backed component) | Expected for a stale page; handle by removing the row or reloading |
91
+ | **Nothing happens on click** | The `reactive` controller isn't registered, or a host catch-all route shadows `POST /reactive/actions` | `bin/rails phlex_reactive:doctor` names both — fix the ✗ |
92
+ | **`importmap` 404 for the client** | The engine's pin was overridden | Re-add `pin "phlex/reactive/reactive_controller"`; doctor flags it |
93
+ | **Component renders but never updates** | The root's `id` and `data-controller="reactive"` landed on DIFFERENT elements | Spread `**reactive_root` (binds id + controller + token to the same element) instead of `**reactive_attrs` on a child |
94
+
95
+ ## Reference vs live introspection
96
+
97
+ - **Reference docs** (how a feature works, the API): the hosted docs MCP at
98
+ <https://phlex-reactive.zoolutions.llc/llms.txt> — search/read the published
99
+ guide.
100
+ - **Live-app introspection** (what's in THIS app right now): the local
101
+ `phlex_reactive:mcp` server + the rake tasks + the browser `report()`.
102
+
103
+ For transport-level problems (a broadcast never arrives over pgbus / Action
104
+ Cable), reach for pgbus's own tooling (`pgbus doctor`, its MCP) — phlex-reactive
105
+ is transport-agnostic and doesn't diagnose the wire.
@@ -58,6 +58,41 @@ module Phlex
58
58
  Registry.resolve_list(self, :state_keys, :reactive_state_keys)
59
59
  end
60
60
 
61
+ # Lazy initial mount (issue #165): the FIRST (page-embedded) render
62
+ # emits the placeholder shell + a defer token on the root; the client
63
+ # fetches the real content on connect. Reactive machinery renders
64
+ # (replies, broadcasts, the defer endpoint/job) stay REAL. Inherited
65
+ # by subclasses. See Component::Lazy.
66
+ #
67
+ # `tag:` sets the shell element (default :div). Set it to the real
68
+ # root's element for content that can't legally hold a <div> — a
69
+ # `reactive_lazy tag: :tr` component rooting at <tr> inside <tbody>
70
+ # ships a <tr> shell, not a <div> the HTML parser would hoist away.
71
+ def reactive_lazy(tag: :div)
72
+ Registry.write_scalar(self, :lazy, { tag: tag.to_sym })
73
+ end
74
+
75
+ # The RAW resolved lazy declaration ({ tag: } or nil) — the reader the
76
+ # Registry walks up the ancestry. It MUST return the stored value, not
77
+ # a boolean: resolve_scalar inherits whatever the superclass reader
78
+ # returns, so a boolean false would be inherited as the value and read
79
+ # back as "declared" (`!false.nil?` == true) — a subclass of any
80
+ # component would spuriously render the lazy shell.
81
+ def reactive_lazy_declaration
82
+ Registry.resolve_scalar(self, :lazy, :reactive_lazy_declaration)
83
+ end
84
+
85
+ def reactive_lazy?
86
+ !reactive_lazy_declaration.nil?
87
+ end
88
+
89
+ # The shell element tag for a lazy component (:div default), resolved
90
+ # through the registry so a subclass inherits the parent's choice.
91
+ def reactive_lazy_tag
92
+ value = reactive_lazy_declaration
93
+ value.is_a?(::Hash) ? value.fetch(:tag, :div) : :div
94
+ end
95
+
61
96
  # Declare a client-invokable action with an optional param schema.
62
97
  # action :increment
63
98
  # action :rename, params: { title: :string }
@@ -100,6 +135,41 @@ module Phlex
100
135
  reactive_actions.key?(name.to_sym)
101
136
  end
102
137
 
138
+ # Opt out of the default-ON verify_authorized guard (issue #168).
139
+ # Bare skips the WHOLE component (a public counter, a client-only
140
+ # filter); with names skips just those actions:
141
+ #
142
+ # skip_verify_authorized # every action
143
+ # skip_verify_authorized :filter, :page # only these
144
+ #
145
+ # Registry #6 — resolves through the superclass at read time like the
146
+ # other five, so a child inherits a parent's skips (and a child's bare
147
+ # skip covers everything). Marks nothing else; a non-skipped action
148
+ # still needs a real authorization call or mark_authorized!.
149
+ def skip_verify_authorized(*names)
150
+ if names.empty?
151
+ Registry.write_scalar(self, :skip_all, true)
152
+ else
153
+ Registry.append(self, :skip_actions, names.map(&:to_sym))
154
+ end
155
+ end
156
+
157
+ def reactive_skip_all_authorization?
158
+ Registry.resolve_scalar(self, :skip_all, :reactive_skip_all_authorization?) == true
159
+ end
160
+
161
+ def reactive_skip_authorization_actions
162
+ Registry.resolve_list(self, :skip_actions, :reactive_skip_authorization_actions)
163
+ end
164
+
165
+ # Is verify_authorized skipped for `action_name`? True under a bare
166
+ # component skip OR when the action is in the named list — resolved
167
+ # through Registry so inheritance matches every other declaration.
168
+ def skip_verify_authorized?(action_name)
169
+ reactive_skip_all_authorization? ||
170
+ reactive_skip_authorization_actions.include?(action_name.to_sym)
171
+ end
172
+
103
173
  # Declare an add/remove-row collection (issue #35) — the list contract
104
174
  # in one place, so actions append/prepend/remove a row WITHOUT
105
175
  # re-deriving the container id, count, and empty-state in every action: