funicular 0.4.0 → 0.5.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 +429 -1
- data/demo/local_notes.html +207 -0
- data/docs/architecture.md +181 -3
- data/docs/local_database.md +1035 -0
- data/lib/funicular/assets/funicular.rb +14 -0
- data/lib/funicular/configuration.rb +65 -0
- data/lib/funicular/epoch_header.rb +69 -0
- data/lib/funicular/epoch_stamping.rb +66 -0
- data/lib/funicular/helpers/picoruby_helper.rb +96 -1
- data/lib/funicular/railtie.rb +30 -0
- data/lib/funicular/schema.rb +45 -12
- data/lib/funicular/session_epoch.rb +110 -0
- data/lib/funicular/ssr/runtime.rb +57 -12
- data/lib/funicular/ssr.rb +25 -0
- data/lib/funicular/testing/node_runner.mjs +19 -0
- data/lib/funicular/testing.rb +47 -0
- data/lib/funicular/vendor/mrbc/VERSION +1 -1
- data/lib/funicular/vendor/mrbc/mrbc.js +69 -115
- data/lib/funicular/vendor/mrbc/mrbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -1
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +170 -120
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -1
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +2 -7201
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +1 -0
- data/lib/tasks/funicular.rake +10 -2
- data/minitest/callback_error_visibility_test.rb +48 -0
- data/minitest/configuration_test.rb +78 -0
- data/minitest/dsl_test.rb +27 -0
- data/minitest/epoch_header_test.rb +149 -0
- data/minitest/epoch_stamping_test.rb +225 -0
- data/minitest/fixtures/funicular_app/components/probe_component.rb +15 -0
- data/minitest/navigation_guard_test.rb +65 -0
- data/minitest/picoruby_helper_test.rb +236 -0
- data/minitest/schema_test.rb +47 -0
- data/minitest/session_epoch_test.rb +122 -0
- data/minitest/ssr_database_test.rb +78 -0
- data/minitest/ssr_reload_test.rb +106 -0
- data/minitest/ssr_test.rb +41 -0
- data/minitest/testing_ensure_compiled_test.rb +52 -0
- data/minitest/validations_test.rb +35 -5
- data/mrbgem.rake +2 -0
- data/mrblib/cable.rb +1 -1
- data/mrblib/component.rb +113 -1
- data/mrblib/db.rb +3116 -0
- data/mrblib/file_upload.rb +17 -7
- data/mrblib/funicular.rb +136 -17
- data/mrblib/http.rb +84 -107
- data/mrblib/model.rb +1178 -23
- data/mrblib/relation.rb +342 -0
- data/mrblib/router.rb +45 -4
- data/mrblib/styles.rb +20 -0
- data/sig/component.rbs +7 -0
- data/sig/db.rbs +328 -0
- data/sig/funicular.rbs +5 -0
- data/sig/http.rbs +8 -21
- data/sig/model.rbs +101 -7
- data/sig/relation.rbs +44 -0
- data/sig/router.rbs +1 -0
- data/sig/styles.rbs +1 -0
- metadata +19 -2
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +0 -1
data/mrblib/relation.rb
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# A lazy, chainable query over one local table (docs/local_database.md,
|
|
2
|
+
# "Querying"). Chain builders (where/order/limit/offset) each return a NEW
|
|
3
|
+
# Relation; SQL executes once, in a materializer.
|
|
4
|
+
#
|
|
5
|
+
# The `model` handed to the constructor is the query's metadata + row
|
|
6
|
+
# factory. Relation calls exactly these methods on it:
|
|
7
|
+
#
|
|
8
|
+
# table_name -> String
|
|
9
|
+
# local_columns -> Hash[String => Symbol] (column name -> declared type)
|
|
10
|
+
# local_db -> handle responding to execute(sql, binds) -> rows
|
|
11
|
+
# replica? -> bool (delete_all guard)
|
|
12
|
+
# build_from_local -> (Hash) -> model instance (attrs already decoded)
|
|
13
|
+
# local_table_changed -> void; called after a framework-managed write
|
|
14
|
+
# (delete_all here) so the model can fire change
|
|
15
|
+
# events and schedule snapshot persistence
|
|
16
|
+
#
|
|
17
|
+
# Identifiers in hash conditions and order() are validated against
|
|
18
|
+
# local_columns and double-quoted; values cross into SQL through
|
|
19
|
+
# Funicular::DB::Codec. Raw SQL fragments pass through untouched, their
|
|
20
|
+
# binds encoded by value (Codec.encode_bind).
|
|
21
|
+
|
|
22
|
+
module Funicular
|
|
23
|
+
class Relation
|
|
24
|
+
# The trailing arguments are internal: chain builders use them to spawn
|
|
25
|
+
# derived relations. Application code passes only `model`.
|
|
26
|
+
def initialize(model, where_sql = [], where_binds = [], order_sql = [],
|
|
27
|
+
limit = nil, offset = nil)
|
|
28
|
+
@model = model
|
|
29
|
+
@where_sql = where_sql
|
|
30
|
+
@where_binds = where_binds
|
|
31
|
+
@order_sql = order_sql
|
|
32
|
+
@limit = limit
|
|
33
|
+
@offset = offset
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# ---- chain builders --------------------------------------------------
|
|
37
|
+
|
|
38
|
+
# where(title: "x") equality (nil -> IS NULL)
|
|
39
|
+
# where(id: [1, 2]) IN (empty array -> WHERE 1=0)
|
|
40
|
+
# where(id: 1..9) BETWEEN (exclusive end -> >= AND <)
|
|
41
|
+
# where("title LIKE ?", "a%") raw fragment with placeholders
|
|
42
|
+
# Multiple where calls AND together.
|
|
43
|
+
def where(conditions = nil, *binds)
|
|
44
|
+
w = @where_sql.dup
|
|
45
|
+
b = @where_binds.dup
|
|
46
|
+
if conditions.is_a?(Hash)
|
|
47
|
+
keys = conditions.keys
|
|
48
|
+
i = 0
|
|
49
|
+
while i < keys.size
|
|
50
|
+
append_condition(w, b, keys[i], conditions[keys[i]])
|
|
51
|
+
i += 1
|
|
52
|
+
end
|
|
53
|
+
elsif conditions.is_a?(String)
|
|
54
|
+
w << "(#{conditions})"
|
|
55
|
+
i = 0
|
|
56
|
+
while i < binds.size
|
|
57
|
+
b << Funicular::DB::Codec.encode_bind(binds[i])
|
|
58
|
+
i += 1
|
|
59
|
+
end
|
|
60
|
+
else
|
|
61
|
+
raise ArgumentError,
|
|
62
|
+
"where expects a Hash or a SQL fragment String, got #{conditions.inspect}"
|
|
63
|
+
end
|
|
64
|
+
Relation.new(@model, w, b, @order_sql, @limit, @offset)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# order(:created_at) ASC
|
|
68
|
+
# order(created_at: :desc)
|
|
69
|
+
# order(:pinned, created_at: :desc) multiple keys
|
|
70
|
+
def order(*args)
|
|
71
|
+
if args.empty?
|
|
72
|
+
raise ArgumentError, "order requires at least one column"
|
|
73
|
+
end
|
|
74
|
+
o = @order_sql.dup
|
|
75
|
+
i = 0
|
|
76
|
+
while i < args.size
|
|
77
|
+
arg = args[i]
|
|
78
|
+
if arg.is_a?(Hash)
|
|
79
|
+
keys = arg.keys
|
|
80
|
+
j = 0
|
|
81
|
+
while j < keys.size
|
|
82
|
+
o << order_term(keys[j], arg[keys[j]])
|
|
83
|
+
j += 1
|
|
84
|
+
end
|
|
85
|
+
else
|
|
86
|
+
o << order_term(arg, :asc)
|
|
87
|
+
end
|
|
88
|
+
i += 1
|
|
89
|
+
end
|
|
90
|
+
Relation.new(@model, @where_sql, @where_binds, o, @limit, @offset)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def limit(n)
|
|
94
|
+
Relation.new(@model, @where_sql, @where_binds, @order_sql,
|
|
95
|
+
slice_arg(n, "limit"), @offset)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def offset(n)
|
|
99
|
+
Relation.new(@model, @where_sql, @where_binds, @order_sql,
|
|
100
|
+
@limit, slice_arg(n, "offset"))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# ---- materializers ---------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def to_a
|
|
106
|
+
rows = @model.local_db.execute(select_sql, @where_binds)
|
|
107
|
+
cols = @model.local_columns.keys
|
|
108
|
+
# @type var out: Array[untyped]
|
|
109
|
+
out = []
|
|
110
|
+
i = 0
|
|
111
|
+
while i < rows.size
|
|
112
|
+
out << @model.build_from_local(decode_row(cols, rows[i]))
|
|
113
|
+
i += 1
|
|
114
|
+
end
|
|
115
|
+
out
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def each
|
|
119
|
+
list = to_a
|
|
120
|
+
i = 0
|
|
121
|
+
while i < list.size
|
|
122
|
+
yield list[i]
|
|
123
|
+
i += 1
|
|
124
|
+
end
|
|
125
|
+
list
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Instance or nil. Narrows the window to one row (never widens: a
|
|
129
|
+
# relation already limited to 0 stays empty).
|
|
130
|
+
def first
|
|
131
|
+
lim = @limit
|
|
132
|
+
lim = (lim.nil? || 1 <= lim) ? 1 : lim
|
|
133
|
+
Relation.new(@model, @where_sql, @where_binds, @order_sql,
|
|
134
|
+
lim, @offset).to_a[0]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# SELECT COUNT(*); on a limited/offset relation it counts the window
|
|
138
|
+
# (COUNT over a subquery), matching ActiveRecord.
|
|
139
|
+
def count
|
|
140
|
+
sql = if @limit || @offset
|
|
141
|
+
"SELECT COUNT(*) FROM (#{select_sql})"
|
|
142
|
+
else
|
|
143
|
+
"SELECT COUNT(*) FROM #{quoted_table}#{where_clause}"
|
|
144
|
+
end
|
|
145
|
+
single_value(sql)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def exists?
|
|
149
|
+
if @limit || @offset
|
|
150
|
+
0 < count
|
|
151
|
+
else
|
|
152
|
+
sql = "SELECT 1 FROM #{quoted_table}#{where_clause} LIMIT 1"
|
|
153
|
+
!@model.local_db.execute(sql, @where_binds).empty?
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def find(id)
|
|
158
|
+
record = find_by(id: id)
|
|
159
|
+
unless record
|
|
160
|
+
raise Funicular::RecordNotFound,
|
|
161
|
+
"Couldn't find #{model_label} with id=#{id}"
|
|
162
|
+
end
|
|
163
|
+
record
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def find_by(conditions)
|
|
167
|
+
where(conditions).first
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Bulk delete, `storage :local` models only; returns the number of
|
|
171
|
+
# deleted rows. Raises on a relation carrying order/limit/offset (say
|
|
172
|
+
# what you mean with a plain condition).
|
|
173
|
+
def delete_all
|
|
174
|
+
if @model.replica?
|
|
175
|
+
raise Funicular::DB::ReplicaWriteError,
|
|
176
|
+
"delete_all is not available on replica models; the server owns " \
|
|
177
|
+
"replica rows (deletions reach the replica through destroy)"
|
|
178
|
+
end
|
|
179
|
+
if @limit || @offset || !@order_sql.empty?
|
|
180
|
+
raise ArgumentError,
|
|
181
|
+
"delete_all does not support order/limit/offset"
|
|
182
|
+
end
|
|
183
|
+
# RETURNING counts the deletions inside the one statement; reading
|
|
184
|
+
# SELECT changes() afterwards would race other Tasks writing on the
|
|
185
|
+
# same connection between the two calls.
|
|
186
|
+
rows = @model.local_db.execute(
|
|
187
|
+
"DELETE FROM #{quoted_table}#{where_clause} RETURNING \"id\"",
|
|
188
|
+
@where_binds)
|
|
189
|
+
count = rows.size
|
|
190
|
+
# Framework-managed writes must notify (docs, "Querying"); a delete
|
|
191
|
+
# that removed nothing changed nothing.
|
|
192
|
+
@model.local_table_changed if 0 < count
|
|
193
|
+
count
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The SELECT this relation will run (debugging aid; binds not inlined).
|
|
197
|
+
def to_sql
|
|
198
|
+
select_sql
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Internal (Component#watch): where change events for this
|
|
202
|
+
# relation's rows come from, as [role, table].
|
|
203
|
+
def __event_source
|
|
204
|
+
m = @model
|
|
205
|
+
[m.replica? ? :replica : :local, m.table_name]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# ---- internal --------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def append_condition(w, b, col, value)
|
|
213
|
+
name = validate_column(col)
|
|
214
|
+
q = quote(name)
|
|
215
|
+
type = @model.local_columns[name]
|
|
216
|
+
codec = Funicular::DB::Codec
|
|
217
|
+
if value.nil?
|
|
218
|
+
w << "#{q} IS NULL"
|
|
219
|
+
elsif value.is_a?(Array)
|
|
220
|
+
if value.empty?
|
|
221
|
+
w << "1=0"
|
|
222
|
+
else
|
|
223
|
+
# @type var marks: Array[String]
|
|
224
|
+
marks = []
|
|
225
|
+
i = 0
|
|
226
|
+
while i < value.size
|
|
227
|
+
marks << "?"
|
|
228
|
+
b << codec.encode(type, value[i])
|
|
229
|
+
i += 1
|
|
230
|
+
end
|
|
231
|
+
w << "#{q} IN (#{marks.join(", ")})"
|
|
232
|
+
end
|
|
233
|
+
elsif value.is_a?(Range)
|
|
234
|
+
b << codec.encode(type, value.begin)
|
|
235
|
+
b << codec.encode(type, value.end)
|
|
236
|
+
if value.exclude_end?
|
|
237
|
+
w << "(#{q} >= ? AND #{q} < ?)"
|
|
238
|
+
else
|
|
239
|
+
w << "#{q} BETWEEN ? AND ?"
|
|
240
|
+
end
|
|
241
|
+
else
|
|
242
|
+
w << "#{q} = ?"
|
|
243
|
+
b << codec.encode(type, value)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def order_term(col, dir)
|
|
248
|
+
d = dir.to_s.downcase
|
|
249
|
+
unless d == "asc" || d == "desc"
|
|
250
|
+
raise ArgumentError,
|
|
251
|
+
"order direction must be :asc or :desc, got #{dir.inspect}"
|
|
252
|
+
end
|
|
253
|
+
"#{quote(validate_column(col))} #{d == "asc" ? "ASC" : "DESC"}"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def validate_column(col)
|
|
257
|
+
name = col.to_s
|
|
258
|
+
unless @model.local_columns.has_key?(name)
|
|
259
|
+
raise ArgumentError,
|
|
260
|
+
"unknown column #{name.inspect} for table \"#{@model.table_name}\""
|
|
261
|
+
end
|
|
262
|
+
name
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def quote(name)
|
|
266
|
+
"\"#{name}\""
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def quoted_table
|
|
270
|
+
quote(@model.table_name)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def select_sql
|
|
274
|
+
cols = @model.local_columns.keys
|
|
275
|
+
# @type var quoted: Array[String]
|
|
276
|
+
quoted = []
|
|
277
|
+
i = 0
|
|
278
|
+
while i < cols.size
|
|
279
|
+
quoted << quote(cols[i])
|
|
280
|
+
i += 1
|
|
281
|
+
end
|
|
282
|
+
"SELECT #{quoted.join(", ")} FROM #{quoted_table}" \
|
|
283
|
+
"#{where_clause}#{order_clause}#{slice_clause}"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def where_clause
|
|
287
|
+
w = @where_sql
|
|
288
|
+
w.empty? ? "" : " WHERE #{w.join(" AND ")}"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def order_clause
|
|
292
|
+
o = @order_sql
|
|
293
|
+
o.empty? ? "" : " ORDER BY #{o.join(", ")}"
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# LIMIT -1 OFFSET n is how SQLite spells offset-without-limit.
|
|
297
|
+
def slice_clause
|
|
298
|
+
lim = @limit
|
|
299
|
+
off = @offset
|
|
300
|
+
if lim
|
|
301
|
+
off ? " LIMIT #{lim} OFFSET #{off}" : " LIMIT #{lim}"
|
|
302
|
+
elsif off
|
|
303
|
+
" LIMIT -1 OFFSET #{off}"
|
|
304
|
+
else
|
|
305
|
+
""
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def slice_arg(n, label)
|
|
310
|
+
return nil if n.nil?
|
|
311
|
+
unless n.is_a?(Integer) && 0 <= n
|
|
312
|
+
raise ArgumentError, "#{label} must be a non-negative Integer, got #{n.inspect}"
|
|
313
|
+
end
|
|
314
|
+
n
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def decode_row(cols, row)
|
|
318
|
+
codec = Funicular::DB::Codec
|
|
319
|
+
columns = @model.local_columns
|
|
320
|
+
# @type var attrs: Hash[String, untyped]
|
|
321
|
+
attrs = {}
|
|
322
|
+
i = 0
|
|
323
|
+
while i < cols.size
|
|
324
|
+
name = cols[i]
|
|
325
|
+
raw = row.is_a?(Hash) ? row[name] : row[i]
|
|
326
|
+
attrs[name] = codec.decode(columns[name], raw)
|
|
327
|
+
i += 1
|
|
328
|
+
end
|
|
329
|
+
attrs
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def single_value(sql)
|
|
333
|
+
row = @model.local_db.execute(sql, @where_binds)[0]
|
|
334
|
+
row.is_a?(Hash) ? row.values[0] : row[0]
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def model_label
|
|
338
|
+
m = @model
|
|
339
|
+
m.respond_to?(:name) ? m.name : m.table_name
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
data/mrblib/router.rb
CHANGED
|
@@ -9,6 +9,7 @@ module Funicular
|
|
|
9
9
|
@current_component = nil
|
|
10
10
|
@current_path = nil
|
|
11
11
|
@popstate_callback_id = nil
|
|
12
|
+
@beforeunload_callback_id = nil
|
|
12
13
|
@url_helpers = Module.new
|
|
13
14
|
@route_helpers = Object.new
|
|
14
15
|
@route_helpers.extend(@url_helpers)
|
|
@@ -62,15 +63,38 @@ module Funicular
|
|
|
62
63
|
|
|
63
64
|
@hydrate_initial = hydrate
|
|
64
65
|
|
|
65
|
-
# Clean up existing
|
|
66
|
+
# Clean up existing listeners if any (prevents duplicate registration)
|
|
66
67
|
if @popstate_callback_id
|
|
67
68
|
JS::Object.removeEventListener(@popstate_callback_id)
|
|
68
69
|
@popstate_callback_id = nil
|
|
69
70
|
end
|
|
71
|
+
if @beforeunload_callback_id
|
|
72
|
+
JS::Object.removeEventListener(@beforeunload_callback_id)
|
|
73
|
+
@beforeunload_callback_id = nil
|
|
74
|
+
end
|
|
70
75
|
|
|
71
|
-
# Set up popstate listener
|
|
76
|
+
# Set up popstate listener. The history entry has already moved by
|
|
77
|
+
# the time popstate fires; when the current component's navigation
|
|
78
|
+
# guard vetoes leaving, push the guarded path back (the one thing
|
|
79
|
+
# popstate cannot cancel).
|
|
72
80
|
@popstate_callback_id = JS.global.addEventListener('popstate') do |event|
|
|
73
|
-
|
|
81
|
+
if leave_allowed?
|
|
82
|
+
handle_route_change
|
|
83
|
+
elsif (guarded_path = @current_path)
|
|
84
|
+
# Local binding: the type checker does not narrow ivars
|
|
85
|
+
# through elsif.
|
|
86
|
+
JS.global.history.pushState(JS::Bridge.to_js({}), '', guarded_path)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Reload / tab close: ask through the browser's native dialog when
|
|
91
|
+
# a guard is active. sync: the decision must be made on the JS
|
|
92
|
+
# event dispatch stack, so the guard must not suspend.
|
|
93
|
+
@beforeunload_callback_id = JS.global.addEventListener('beforeunload', sync: true) do |event|
|
|
94
|
+
if @current_component&.navigation_guard
|
|
95
|
+
event.preventDefault
|
|
96
|
+
event[:returnValue] = ''
|
|
97
|
+
end
|
|
74
98
|
end
|
|
75
99
|
|
|
76
100
|
# Handle initial route. Skip the default-route redirect when hydrating
|
|
@@ -90,16 +114,33 @@ module Funicular
|
|
|
90
114
|
@popstate_callback_id = nil
|
|
91
115
|
end
|
|
92
116
|
|
|
117
|
+
if @beforeunload_callback_id
|
|
118
|
+
JS::Object.removeEventListener(@beforeunload_callback_id)
|
|
119
|
+
@beforeunload_callback_id = nil
|
|
120
|
+
end
|
|
121
|
+
|
|
93
122
|
unmount_current_component
|
|
94
123
|
end
|
|
95
124
|
|
|
96
|
-
# Navigate to a path programmatically using History API
|
|
125
|
+
# Navigate to a path programmatically using History API. A vetoing
|
|
126
|
+
# navigation guard on the current component cancels the navigation
|
|
127
|
+
# before any history change.
|
|
97
128
|
def navigate(path)
|
|
129
|
+
return unless leave_allowed?
|
|
98
130
|
JS.global.history.pushState(JS::Bridge.to_js({}), '', path)
|
|
99
131
|
# Manually trigger route change because pushState doesn't fire popstate
|
|
100
132
|
handle_route_change
|
|
101
133
|
end
|
|
102
134
|
|
|
135
|
+
# Ask the current component's navigation guard whether leaving is
|
|
136
|
+
# allowed; a String from the guard prompts the user via
|
|
137
|
+
# Funicular.confirm. True when no component or no guard.
|
|
138
|
+
def leave_allowed?
|
|
139
|
+
message = @current_component&.navigation_guard
|
|
140
|
+
return true unless message
|
|
141
|
+
Funicular.confirm(message)
|
|
142
|
+
end
|
|
143
|
+
|
|
103
144
|
# Get current path from location
|
|
104
145
|
def current_location_path
|
|
105
146
|
js_path_obj = JS.global.location.pathname
|
data/mrblib/styles.rb
CHANGED
|
@@ -20,6 +20,26 @@ module Funicular
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# String-like concatenation: styles.field + " col-span-2". No space
|
|
24
|
+
# is inserted and non-String operands raise TypeError, matching
|
|
25
|
+
# String#+ (a silent to_s would hide mistakes like `+ 123`); use |
|
|
26
|
+
# to join with a space.
|
|
27
|
+
# to_str is deliberately not defined: the mruby client's String#+
|
|
28
|
+
# never coerces (no implicit to_str call), so defining it on CRuby
|
|
29
|
+
# would let SSR accept "base " + styles.field while the browser
|
|
30
|
+
# raises. Both VMs reject the reversed form the same way instead.
|
|
31
|
+
def +(other)
|
|
32
|
+
case other
|
|
33
|
+
when StyleValue
|
|
34
|
+
StyleValue.new(@value + other.value)
|
|
35
|
+
when String
|
|
36
|
+
StyleValue.new(@value + other)
|
|
37
|
+
else
|
|
38
|
+
other = other #: untyped
|
|
39
|
+
raise TypeError, "no implicit conversion of #{other.class} into String"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
23
43
|
def to_s
|
|
24
44
|
@value
|
|
25
45
|
end
|
data/sig/component.rbs
CHANGED
|
@@ -47,6 +47,11 @@ module Funicular
|
|
|
47
47
|
def initialize_state: () -> Hash[Symbol, untyped]
|
|
48
48
|
def seed_state: (Hash[untyped, untyped]? state_hash) -> self
|
|
49
49
|
|
|
50
|
+
@__watches: Hash[Symbol, Integer]?
|
|
51
|
+
def watch: (Symbol key) { () -> untyped } -> nil
|
|
52
|
+
def evaluate_watch: (Symbol key, untyped block) -> nil
|
|
53
|
+
def cleanup_watches: () -> nil
|
|
54
|
+
|
|
50
55
|
def load_suspense_data: () -> void
|
|
51
56
|
def load_single_suspense: (Symbol name, ?suspense_definition? definition) -> void
|
|
52
57
|
def reload_suspense: (Symbol name) -> void
|
|
@@ -55,6 +60,7 @@ module Funicular
|
|
|
55
60
|
def suspense_error: (Symbol name) -> untyped
|
|
56
61
|
def render_suspense: (Symbol name, fallback: untyped, ?error: untyped) { (ResourceAccessor) -> untyped } -> untyped
|
|
57
62
|
|
|
63
|
+
def navigation_guard: () -> String?
|
|
58
64
|
def patch: (Hash[Symbol, untyped] new_state) -> void
|
|
59
65
|
def mount: (JS::Element container) -> void
|
|
60
66
|
def hydrate: (JS::Element dom_element) -> void
|
|
@@ -63,6 +69,7 @@ module Funicular
|
|
|
63
69
|
def build_vdom: () -> (VDOM::VNode | VDOM::Text | nil)
|
|
64
70
|
|
|
65
71
|
def bind_events: (JS::Element dom_element, VDOM::VNode | VDOM::Text | nil vnode) -> void
|
|
72
|
+
def report_handler_error: (String event_name, String handler, StandardError error) -> void
|
|
66
73
|
def collect_refs: (JS::Element dom_element, VDOM::VNode | VDOM::Text | nil vnode, ?Hash[Symbol, JS::Element] refs_map) -> Hash[Symbol, JS::Element]
|
|
67
74
|
def normalize_vnode_for_view: (untyped value) -> (VDOM::Element | VDOM::Text | VDOM::Component | nil)
|
|
68
75
|
def __view__: () -> ViewContext
|