phlex-reactive 0.4.8 → 0.9.0

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +868 -3
  3. data/README.md +986 -32
  4. data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
  5. data/app/javascript/phlex/reactive/compute.js +52 -7
  6. data/app/javascript/phlex/reactive/compute.min.js +4 -0
  7. data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
  8. data/app/javascript/phlex/reactive/confirm.min.js +4 -0
  9. data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
  10. data/app/javascript/phlex/reactive/reactive_controller.js +1487 -80
  11. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
  12. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
  13. data/lib/generators/phlex/reactive/component/USAGE +2 -1
  14. data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
  15. data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
  16. data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
  17. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
  18. data/lib/phlex/reactive/component/dsl.rb +249 -0
  19. data/lib/phlex/reactive/component/helpers.rb +595 -0
  20. data/lib/phlex/reactive/component/identity.rb +92 -0
  21. data/lib/phlex/reactive/component/registry.rb +200 -0
  22. data/lib/phlex/reactive/component.rb +30 -442
  23. data/lib/phlex/reactive/doctor.rb +333 -0
  24. data/lib/phlex/reactive/engine.rb +27 -9
  25. data/lib/phlex/reactive/js.rb +222 -0
  26. data/lib/phlex/reactive/log_subscriber.rb +64 -0
  27. data/lib/phlex/reactive/param_schema.rb +390 -0
  28. data/lib/phlex/reactive/reply.rb +5 -3
  29. data/lib/phlex/reactive/response.rb +156 -16
  30. data/lib/phlex/reactive/stream.rb +98 -0
  31. data/lib/phlex/reactive/streamable.rb +307 -43
  32. data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
  33. data/lib/phlex/reactive/test_helpers.rb +308 -0
  34. data/lib/phlex/reactive/version.rb +1 -1
  35. data/lib/phlex/reactive.rb +329 -14
  36. data/lib/tasks/phlex_reactive.rake +14 -0
  37. metadata +19 -1
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # The signed-identity half of Component (issue #115): reactive_token —
7
+ # the payload signed into the DOM root on EVERY render — plus the
8
+ # class-level ivar precomputation that keeps that render path
9
+ # allocation-free.
10
+ #
11
+ # THE token hot path. Both memos below are bare defined?/||= fast paths
12
+ # ON PURPOSE: a render must never pay a per-read generation compare
13
+ # (issue #115 invariant, .claude/rules/performance.md). Coherence comes
14
+ # from the WRITE side instead — Component::Registry.bump! sweeps these
15
+ # memos off the declaring class and all its descendants whenever any
16
+ # registry declaration lands (class-load-shaped, rare), so they can't go
17
+ # stale even when an ancestor re-declares after a subclass memoized.
18
+ module Identity
19
+ extend ActiveSupport::Concern
20
+
21
+ class_methods do
22
+ # The record's instance-variable symbol (e.g. :@todo), computed once.
23
+ # reactive_token reads it on every render; interpolating :"@#{key}" each
24
+ # time would allocate a symbol per render. Nil when record-less.
25
+ def reactive_record_ivar
26
+ return @reactive_record_ivar if defined?(@reactive_record_ivar)
27
+
28
+ @reactive_record_ivar = reactive_record_key ? :"@#{reactive_record_key}" : nil
29
+ end
30
+
31
+ # [string_key, ivar_symbol] pairs for the signed state, computed once.
32
+ # reactive_token walks these every render; precomputing the "count"/:@count
33
+ # forms avoids a String + Symbol allocation per key per render.
34
+ def reactive_state_ivars
35
+ @reactive_state_ivars ||= reactive_state_keys.map { [it.to_s, :"@#{it}"] }
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # Signed identity payload: the class name plus whichever identity pieces
42
+ # the component declares — a record GlobalID (`gid`), signed state (`s`),
43
+ # or both. Keeping them in ONE MessageVerifier payload makes the state
44
+ # (e.g. which column an inline_edit may write) tamper-proof alongside the
45
+ # record. Record-only ({c, gid}) and state-only ({c, s}) shapes are
46
+ # unchanged.
47
+ #
48
+ # A record that is NOT YET PERSISTED (new_record?) has no id → no GlobalID
49
+ # (to_gid raises MissingModelIdError). A record-backed component may render
50
+ # such a draft (an unsaved order the user is building): we OMIT gid and rely
51
+ # on the declared state (reactive_state) as the draft seed, so the token
52
+ # still signs cleanly and the client controller mounts. The draft is then
53
+ # driven client-side (reactive_compute) until it's saved; once persisted, a
54
+ # re-render signs the gid as usual. If the record is unsaved AND no state is
55
+ # declared, the token carries just {c} — enough to mount, but with no
56
+ # identity to round-trip, so declare reactive_state for a draft you sync.
57
+ def reactive_token
58
+ klass = self.class
59
+ payload = { "c" => klass.name }
60
+
61
+ # Sign the gid ONLY for a present, persisted record. An unsaved draft
62
+ # (persisted? == false) has no GlobalID — omit gid and let the signed
63
+ # state seed it (the tokenless-draft seam documented above); a nil
64
+ # ivar likewise carries no identity to sign, and must short-circuit
65
+ # before to_gid (nil.to_gid would raise).
66
+ if (record_ivar = klass.reactive_record_ivar) &&
67
+ (record = instance_variable_get(record_ivar)) && signable_gid?(record)
68
+ payload["gid"] = record.to_gid.to_s
69
+ end
70
+
71
+ state_ivars = klass.reactive_state_ivars
72
+ unless state_ivars.empty?
73
+ state = {}
74
+ state_ivars.each { |key, ivar| state[key] = instance_variable_get(ivar).as_json }
75
+ payload["s"] = state
76
+ end
77
+
78
+ Phlex::Reactive.sign(payload)
79
+ end
80
+
81
+ # A record's gid is signable unless it is an unsaved AR-like draft
82
+ # (responds to persisted? and is not persisted) — a new_record has no
83
+ # id and to_gid would raise MissingModelIdError. A record that doesn't
84
+ # respond to persisted? (a plain GlobalID-able object) is treated as
85
+ # signable, matching the pre-existing contract.
86
+ def signable_gid?(record)
87
+ !(record.respond_to?(:persisted?) && !record.persisted?)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # ONE inheritance semantic for every Component class-level registry
7
+ # (issue #115). Before this, the five registries hand-rolled two DIVERGENT
8
+ # patterns: reactive_record_key walked the superclass LIVE on every read,
9
+ # while reactive_actions/reactive_state_keys/reactive_collections/
10
+ # reactive_computes snapshot-dup'd the parent on FIRST access — so a
11
+ # parent declaring an action after a subclass had been read was visible
12
+ # via record-key semantics but silently invisible via action semantics.
13
+ #
14
+ # The unified semantic is the live one: **resolve through the superclass
15
+ # at read time**, memoized per class against a process-wide generation
16
+ # counter bumped on any registry write. Declarations happen at class-load
17
+ # (boot, dev reload), so post-boot reads are pure memo hits; a late
18
+ # declaration anywhere simply invalidates the memos lazily via the
19
+ # generation compare on next read.
20
+ #
21
+ # === The hot-path contract (issue #115 invariant, performance rule) ===
22
+ # The generation check gates registry RESOLUTION only. The identity-side
23
+ # memos read on EVERY reactive_token render — @reactive_record_ivar and
24
+ # @reactive_state_ivars (Component::Identity) — stay bare defined?/||=
25
+ # fast paths with NO per-read comparison. They are invalidated HERE, at
26
+ # write time: bump! sweeps them off the writing class and all its
27
+ # descendants (a declaration is rare and class-load-shaped; a render is
28
+ # not). That write-time sweep is also what fixes the pre-#115 split-brain
29
+ # where a parent's late reactive_record left a subclass's memoized ivar
30
+ # stale against the live reactive_record_key.
31
+ #
32
+ # Storage layout (all on the component class itself, so a Zeitwerk
33
+ # reload's fresh class object starts clean and nothing global retains a
34
+ # reference to app classes):
35
+ # * @reactive_own_* — the class's OWN declarations
36
+ # * @reactive_registry_cache — kind => resolved value
37
+ # * @reactive_registry_generation — the generation the cache was built at
38
+ #
39
+ # Resolved values are fresh copies per class (merge/+), matching the
40
+ # pre-#115 mutability surface: mutating a returned collection never
41
+ # reaches an ancestor. Direct mutation of a returned registry is
42
+ # unsupported either way — it bypasses the generation bump, so the next
43
+ # declaration anywhere discards it. Declare through the DSL.
44
+ module Registry
45
+ # The class ivar carrying each registry's OWN declarations. A frozen
46
+ # lookup table (not :"@reactive_own_#{kind}") so no read or write
47
+ # allocates an intermediate string.
48
+ OWN_IVARS = {
49
+ actions: :@reactive_own_actions,
50
+ state_keys: :@reactive_own_state_keys,
51
+ collections: :@reactive_own_collections,
52
+ computes: :@reactive_own_computes,
53
+ record_key: :@reactive_own_record_key
54
+ }.freeze
55
+
56
+ # The identity-side hot-path memos swept by bump! (see the contract
57
+ # above). Owned by Component::Identity; invalidated here.
58
+ HOT_PATH_MEMOS = %i[@reactive_record_ivar @reactive_state_ivars].freeze
59
+
60
+ # Serializes generation bumps so two concurrent class definitions can't
61
+ # lose an increment (a resolution memoized between the two writes must
62
+ # see a THIRD generation, not the second one again). Reads stay bare —
63
+ # the resolution compare tolerates staleness (it just recomputes).
64
+ WRITE_MUTEX = Mutex.new
65
+
66
+ # Frozen empties for the no-declaration branches of resolve_* — the
67
+ # merge/+ still returns a fresh, per-class copy.
68
+ EMPTY_HASH = {}.freeze
69
+ EMPTY_LIST = [].freeze
70
+
71
+ @generation = 0
72
+
73
+ class << self
74
+ # The process-wide registry generation. Monotonic; never reset (a
75
+ # reloaded class carries no cache ivars, so it rebuilds regardless).
76
+ attr_reader :generation
77
+
78
+ # -- writes (all bump) ------------------------------------------------
79
+
80
+ # Store `key => value` in a Hash-shaped registry (actions,
81
+ # collections, computes). Returns the value, like the Hash#[]= the
82
+ # declarations used pre-#115.
83
+ def write_entry(klass, kind, key, value)
84
+ own!(klass, kind) { {} }[key] = value
85
+ bump!(klass)
86
+ value
87
+ end
88
+
89
+ # Append values to a List-shaped registry (state_keys). Keeps the
90
+ # pre-#115 concat semantics verbatim: declaration order, duplicates
91
+ # included.
92
+ def append(klass, kind, values)
93
+ own!(klass, kind) { [] }.concat(values)
94
+ bump!(klass)
95
+ end
96
+
97
+ # Set a Scalar-shaped registry (record_key). The nearest declaration
98
+ # up the ancestry wins at resolve time.
99
+ def write_scalar(klass, kind, value)
100
+ klass.instance_variable_set(OWN_IVARS.fetch(kind), value)
101
+ bump!(klass)
102
+ value
103
+ end
104
+
105
+ # -- resolution (all memoized against the generation) -----------------
106
+ #
107
+ # `reader` is the PUBLIC reader to walk the superclass through
108
+ # (e.g. :reactive_actions) — the walk must go through the public
109
+ # method so each ancestor memoizes its own resolution and a bare
110
+ # non-Component superclass (respond_to? false) terminates it.
111
+
112
+ # Hash-shaped: ancestors-first merge, nearest declaration winning per
113
+ # key — same key ordering Hash#merge gives the pre-#115 snapshot-dup
114
+ # (inherited entries keep their position, own keys append).
115
+ def resolve_hash(klass, kind, reader)
116
+ fetch(klass, kind) do
117
+ inherited = inherited_value(klass, reader)
118
+ own = own(klass, kind)
119
+ (inherited || EMPTY_HASH).merge(own || EMPTY_HASH)
120
+ end
121
+ end
122
+
123
+ # List-shaped: ancestors' entries first, own appended.
124
+ def resolve_list(klass, kind, reader)
125
+ fetch(klass, kind) do
126
+ inherited = inherited_value(klass, reader)
127
+ own = own(klass, kind)
128
+ (inherited || EMPTY_LIST) + (own || EMPTY_LIST)
129
+ end
130
+ end
131
+
132
+ # Scalar-shaped: own declaration if present (even one an ancestor
133
+ # would override), else the nearest ancestor's resolution, else nil.
134
+ def resolve_scalar(klass, kind, reader)
135
+ fetch(klass, kind) do
136
+ if klass.instance_variable_defined?(OWN_IVARS.fetch(kind))
137
+ klass.instance_variable_get(OWN_IVARS.fetch(kind))
138
+ else
139
+ inherited_value(klass, reader)
140
+ end
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ def own(klass, kind)
147
+ klass.instance_variable_get(OWN_IVARS.fetch(kind))
148
+ end
149
+
150
+ # The class's own storage for `kind`, initialized from the block on
151
+ # first write.
152
+ def own!(klass, kind)
153
+ ivar = OWN_IVARS.fetch(kind)
154
+ klass.instance_variable_get(ivar) || klass.instance_variable_set(ivar, yield)
155
+ end
156
+
157
+ # Invalidate every resolution memo in the process (generation bump)
158
+ # and sweep the hot-path identity memos off the writing class's
159
+ # family (see the contract in the module doc).
160
+ def bump!(klass)
161
+ WRITE_MUTEX.synchronize { @generation += 1 }
162
+ sweep_hot_path_memos(klass)
163
+ end
164
+
165
+ def sweep_hot_path_memos(klass)
166
+ HOT_PATH_MEMOS.each do
167
+ klass.remove_instance_variable(it) if klass.instance_variable_defined?(it)
168
+ end
169
+ klass.subclasses.each { sweep_hot_path_memos(it) }
170
+ end
171
+
172
+ # The generation-checked per-class memo: a cache built at an older
173
+ # generation is discarded wholesale and rebuilt lazily per kind.
174
+ def fetch(klass, kind)
175
+ cache = resolution_cache(klass)
176
+ cache.fetch(kind) { cache[kind] = yield }
177
+ end
178
+
179
+ def resolution_cache(klass)
180
+ generation = @generation
181
+ if klass.instance_variable_get(:@reactive_registry_generation) == generation
182
+ klass.instance_variable_get(:@reactive_registry_cache)
183
+ else
184
+ # Cache before generation: a concurrent reader that sees the new
185
+ # generation must find the new (empty) cache, never the stale one.
186
+ cache = klass.instance_variable_set(:@reactive_registry_cache, {})
187
+ klass.instance_variable_set(:@reactive_registry_generation, generation)
188
+ cache
189
+ end
190
+ end
191
+
192
+ def inherited_value(klass, reader)
193
+ sup = klass.superclass
194
+ sup.public_send(reader) if sup.respond_to?(reader)
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end