phlex-reactive 0.4.7 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +970 -0
- data/README.md +1134 -49
- data/app/controllers/phlex/reactive/actions_controller.rb +282 -190
- data/app/javascript/phlex/reactive/compute.js +93 -0
- data/app/javascript/phlex/reactive/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/confirm.min.js +4 -0
- data/app/javascript/phlex/reactive/confirm.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +1591 -56
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -0
- data/lib/generators/phlex/reactive/component/USAGE +2 -1
- data/lib/generators/phlex/reactive/component/templates/component.rb.erb +1 -2
- data/lib/generators/phlex/reactive/component/templates/component_spec.rb.erb +33 -2
- data/lib/generators/phlex/reactive/install/install_generator.rb +9 -3
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +37 -0
- data/lib/phlex/reactive/component/dsl.rb +249 -0
- data/lib/phlex/reactive/component/helpers.rb +595 -0
- data/lib/phlex/reactive/component/identity.rb +92 -0
- data/lib/phlex/reactive/component/registry.rb +200 -0
- data/lib/phlex/reactive/component.rb +37 -348
- data/lib/phlex/reactive/doctor.rb +333 -0
- data/lib/phlex/reactive/engine.rb +36 -7
- data/lib/phlex/reactive/js.rb +222 -0
- data/lib/phlex/reactive/log_subscriber.rb +64 -0
- data/lib/phlex/reactive/param_schema.rb +390 -0
- data/lib/phlex/reactive/reply.rb +5 -3
- data/lib/phlex/reactive/response.rb +156 -16
- data/lib/phlex/reactive/stream.rb +98 -0
- data/lib/phlex/reactive/streamable.rb +307 -43
- data/lib/phlex/reactive/test_helpers/matchers.rb +112 -0
- data/lib/phlex/reactive/test_helpers.rb +308 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +329 -14
- data/lib/tasks/phlex_reactive.rake +14 -0
- metadata +20 -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
|