cable_ready 5.0.0.pre9 → 5.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +4 -1
  3. data/Gemfile.lock +119 -100
  4. data/README.md +12 -15
  5. data/app/assets/javascripts/cable_ready.js +465 -155
  6. data/app/assets/javascripts/cable_ready.umd.js +449 -169
  7. data/app/channels/cable_ready/stream.rb +7 -5
  8. data/app/helpers/cable_ready/view_helper.rb +58 -0
  9. data/app/jobs/cable_ready/broadcast_job.rb +15 -0
  10. data/app/models/concerns/cable_ready/updatable/collection_updatable_callbacks.rb +2 -0
  11. data/app/models/concerns/cable_ready/updatable/collections_registry.rb +31 -5
  12. data/app/models/concerns/cable_ready/updatable/model_updatable_callbacks.rb +9 -4
  13. data/app/models/concerns/cable_ready/updatable.rb +107 -39
  14. data/app/models/concerns/extend_has_many.rb +2 -0
  15. data/cable_ready.gemspec +4 -6
  16. data/lib/cable_ready/broadcaster.rb +2 -0
  17. data/lib/cable_ready/cable_car.rb +2 -0
  18. data/lib/cable_ready/channel.rb +12 -4
  19. data/lib/cable_ready/channels.rb +3 -1
  20. data/lib/cable_ready/config.rb +17 -2
  21. data/lib/cable_ready/engine.rb +33 -14
  22. data/lib/cable_ready/identifiable.rb +23 -5
  23. data/lib/cable_ready/importmap.rb +3 -1
  24. data/lib/cable_ready/installer.rb +224 -0
  25. data/lib/cable_ready/operation_builder.rb +1 -1
  26. data/lib/cable_ready/sanity_checker.rb +1 -31
  27. data/lib/cable_ready/updatable/memory_cache_debounce_adapter.rb +22 -0
  28. data/lib/cable_ready/version.rb +1 -1
  29. data/lib/cable_ready.rb +5 -8
  30. data/lib/cable_ready_helper.rb +13 -0
  31. data/lib/generators/cable_ready/channel_generator.rb +51 -12
  32. data/lib/generators/cable_ready/templates/config/initializers/cable_ready.rb +15 -6
  33. data/lib/install/action_cable.rb +144 -0
  34. data/lib/install/broadcaster.rb +109 -0
  35. data/lib/install/bundle.rb +54 -0
  36. data/lib/install/compression.rb +51 -0
  37. data/lib/install/config.rb +39 -0
  38. data/lib/install/development.rb +34 -0
  39. data/lib/install/esbuild.rb +101 -0
  40. data/lib/install/importmap.rb +96 -0
  41. data/lib/install/initializers.rb +15 -0
  42. data/lib/install/mrujs.rb +121 -0
  43. data/lib/install/npm_packages.rb +13 -0
  44. data/lib/install/shakapacker.rb +65 -0
  45. data/lib/install/spring.rb +54 -0
  46. data/lib/install/updatable.rb +34 -0
  47. data/lib/install/vite.rb +66 -0
  48. data/lib/install/webpacker.rb +93 -0
  49. data/lib/install/yarn.rb +56 -0
  50. data/lib/tasks/cable_ready/cable_ready.rake +247 -0
  51. data/package.json +36 -22
  52. data/{rollup.config.js → rollup.config.mjs} +7 -25
  53. data/web-test-runner.config.mjs +12 -0
  54. data/yarn.lock +3058 -398
  55. metadata +39 -167
  56. data/IMPLEMENTATION.md +0 -93
  57. data/LATEST +0 -1
  58. data/app/assets/javascripts/cable_ready.min.js +0 -2
  59. data/app/assets/javascripts/cable_ready.min.js.map +0 -1
  60. data/app/assets/javascripts/cable_ready.umd.min.js +0 -2
  61. data/app/assets/javascripts/cable_ready.umd.min.js.map +0 -1
  62. data/app/helpers/cable_ready_helper.rb +0 -26
  63. data/app/jobs/cable_ready_broadcast_job.rb +0 -14
  64. data/lib/generators/cable_ready/helpers_generator.rb +0 -43
  65. data/lib/generators/cable_ready/initializer_generator.rb +0 -14
  66. data/test/dummy/app/channels/application_cable/channel.rb +0 -4
  67. data/test/dummy/app/channels/application_cable/connection.rb +0 -4
  68. data/test/dummy/app/controllers/application_controller.rb +0 -2
  69. data/test/dummy/app/helpers/application_helper.rb +0 -2
  70. data/test/dummy/app/jobs/application_job.rb +0 -7
  71. data/test/dummy/app/mailers/application_mailer.rb +0 -4
  72. data/test/dummy/app/models/application_record.rb +0 -3
  73. data/test/dummy/app/models/dugong.rb +0 -4
  74. data/test/dummy/app/models/global_idable_entity.rb +0 -16
  75. data/test/dummy/app/models/post.rb +0 -4
  76. data/test/dummy/app/models/section.rb +0 -6
  77. data/test/dummy/app/models/team.rb +0 -6
  78. data/test/dummy/app/models/topic.rb +0 -4
  79. data/test/dummy/app/models/user.rb +0 -7
  80. data/test/dummy/config/application.rb +0 -22
  81. data/test/dummy/config/boot.rb +0 -5
  82. data/test/dummy/config/environment.rb +0 -5
  83. data/test/dummy/config/environments/development.rb +0 -76
  84. data/test/dummy/config/environments/production.rb +0 -120
  85. data/test/dummy/config/environments/test.rb +0 -59
  86. data/test/dummy/config/initializers/application_controller_renderer.rb +0 -8
  87. data/test/dummy/config/initializers/assets.rb +0 -12
  88. data/test/dummy/config/initializers/backtrace_silencers.rb +0 -8
  89. data/test/dummy/config/initializers/cable_ready.rb +0 -18
  90. data/test/dummy/config/initializers/content_security_policy.rb +0 -28
  91. data/test/dummy/config/initializers/cookies_serializer.rb +0 -5
  92. data/test/dummy/config/initializers/filter_parameter_logging.rb +0 -6
  93. data/test/dummy/config/initializers/inflections.rb +0 -16
  94. data/test/dummy/config/initializers/mime_types.rb +0 -4
  95. data/test/dummy/config/initializers/permissions_policy.rb +0 -11
  96. data/test/dummy/config/initializers/wrap_parameters.rb +0 -14
  97. data/test/dummy/config/puma.rb +0 -43
  98. data/test/dummy/config/routes.rb +0 -3
  99. data/test/dummy/db/migrate/20210902154139_create_users.rb +0 -9
  100. data/test/dummy/db/migrate/20210902154153_create_posts.rb +0 -10
  101. data/test/dummy/db/migrate/20210904081930_create_topics.rb +0 -9
  102. data/test/dummy/db/migrate/20210904093607_create_sections.rb +0 -9
  103. data/test/dummy/db/migrate/20210913191735_create_teams.rb +0 -8
  104. data/test/dummy/db/migrate/20210913191759_add_team_reference_to_users.rb +0 -5
  105. data/test/dummy/db/migrate/20220329222959_create_dugongs.rb +0 -8
  106. data/test/dummy/db/migrate/20220329230221_create_active_storage_tables.active_storage.rb +0 -36
  107. data/test/dummy/db/schema.rb +0 -84
  108. data/test/dummy/test/models/dugong_test.rb +0 -7
  109. data/test/dummy/test/models/post_test.rb +0 -7
  110. data/test/dummy/test/models/section_test.rb +0 -7
  111. data/test/dummy/test/models/team_test.rb +0 -7
  112. data/test/dummy/test/models/topic_test.rb +0 -7
  113. data/test/dummy/test/models/user_test.rb +0 -7
  114. data/test/lib/cable_ready/cable_car_test.rb +0 -50
  115. data/test/lib/cable_ready/compoundable_test.rb +0 -26
  116. data/test/lib/cable_ready/helper_test.rb +0 -25
  117. data/test/lib/cable_ready/identifiable_test.rb +0 -69
  118. data/test/lib/cable_ready/operation_builder_test.rb +0 -189
  119. data/test/lib/cable_ready/updatable_test.rb +0 -135
  120. data/test/lib/generators/cable_ready/channel_generator_test.rb +0 -157
  121. data/test/support/generator_test_helpers.rb +0 -28
  122. data/test/test_helper.rb +0 -18
@@ -2,53 +2,65 @@ import morphdom from "morphdom";
2
2
 
3
3
  var name = "cable_ready";
4
4
 
5
- var version = "5.0.0-pre9";
5
+ var version = "5.0.0-rc1";
6
6
 
7
7
  var description = "CableReady helps you create great real-time user experiences by making it simple to trigger client-side DOM changes from server-side Ruby.";
8
8
 
9
9
  var keywords = [ "ruby", "rails", "websockets", "actioncable", "cable", "ssr", "stimulus_reflex", "client-side", "dom" ];
10
10
 
11
- var homepage = "https://cableready.stimulusreflex.com/";
11
+ var homepage = "https://cableready.stimulusreflex.com";
12
12
 
13
- var bugs = {
14
- url: "https://github.com/stimulusreflex/cable_ready/issues"
15
- };
13
+ var bugs = "https://github.com/stimulusreflex/cable_ready/issues";
16
14
 
17
- var repository = {
18
- type: "git",
19
- url: "git+https://github.com:stimulusreflex/cable_ready.git"
20
- };
15
+ var repository = "https://github.com/stimulusreflex/cable_ready";
21
16
 
22
17
  var license = "MIT";
23
18
 
24
19
  var author = "Nathan Hopkins <natehop@gmail.com>";
25
20
 
26
- var main = "./dist/cable_ready.umd.min.js";
21
+ var contributors = [ "Andrew Mason <andrewmcodes@protonmail.com>", "Julian Rubisch <julian@julianrubisch.at>", "Marco Roth <marco.roth@intergga.ch>", "Nathan Hopkins <natehop@gmail.com>" ];
22
+
23
+ var main = "./dist/cable_ready.js";
24
+
25
+ var module = "./dist/cable_ready.js";
27
26
 
28
- var module = "./dist/cable_ready.min.js";
27
+ var browser = "./dist/cable_ready.js";
28
+
29
+ var unpkg = "./dist/cable_ready.umd.js";
30
+
31
+ var umd = "./dist/cable_ready.umd.js";
29
32
 
30
33
  var files = [ "dist/*", "javascript/*" ];
31
34
 
32
35
  var scripts = {
33
- lint: "yarn run prettier-standard:check",
34
- format: "yarn run prettier-standard:format",
35
- "prettier-standard:check": "yarn run prettier-standard --check ./javascript/**/*.js rollup.config.js",
36
- "prettier-standard:format": "yarn run prettier-standard ./javascript/**/*.js rollup.config.js",
36
+ lint: "yarn run format --check",
37
+ format: "yarn run prettier-standard ./javascript/**/*.js rollup.config.mjs",
37
38
  build: "yarn rollup -c",
38
- watch: "yarn rollup -wc"
39
+ watch: "yarn rollup -wc",
40
+ test: "web-test-runner javascript/test/**/*.test.js",
41
+ "docs:dev": "vitepress dev docs",
42
+ "docs:build": "vitepress build docs && cp ./docs/_redirects ./docs/.vitepress/dist",
43
+ "docs:preview": "vitepress preview docs"
39
44
  };
40
45
 
41
46
  var dependencies = {
42
- morphdom: "^2.6.1"
47
+ morphdom: "2.6.1"
43
48
  };
44
49
 
45
50
  var devDependencies = {
46
- "@rollup/plugin-commonjs": "^21.0.3",
47
- "@rollup/plugin-json": "^4.1.0",
48
- "@rollup/plugin-node-resolve": "^13.1.3",
51
+ "@open-wc/testing": "^3.1.7",
52
+ "@rollup/plugin-json": "^6.0.0",
53
+ "@rollup/plugin-node-resolve": "^15.0.1",
54
+ "@rollup/plugin-terser": "^0.4.0",
55
+ "@web/dev-server-esbuild": "^0.3.3",
56
+ "@web/dev-server-rollup": "^0.3.21",
57
+ "@web/test-runner": "^0.15.1",
49
58
  "prettier-standard": "^16.4.1",
50
- rollup: "^2.70.1",
51
- "rollup-plugin-terser": "^7.0.2"
59
+ rollup: "^3.19.1",
60
+ sinon: "^15.0.2",
61
+ vite: "^4.1.4",
62
+ vitepress: "^1.0.0-alpha.56",
63
+ "vitepress-plugin-search": "^1.0.4-alpha.19"
52
64
  };
53
65
 
54
66
  var packageInfo = {
@@ -61,8 +73,13 @@ var packageInfo = {
61
73
  repository: repository,
62
74
  license: license,
63
75
  author: author,
76
+ contributors: contributors,
64
77
  main: main,
65
78
  module: module,
79
+ browser: browser,
80
+ import: "./dist/cable_ready.js",
81
+ unpkg: unpkg,
82
+ umd: umd,
66
83
  files: files,
67
84
  scripts: scripts,
68
85
  dependencies: dependencies,
@@ -113,34 +130,90 @@ var ActiveElement = {
113
130
  }
114
131
  };
115
132
 
133
+ // Indicates if the passed element is considered a text input.
134
+
116
135
  const isTextInput = element => inputTags[element.tagName] && textInputTypes[element.type];
117
136
 
137
+ // Assigns focus to the appropriate element... preferring the explicitly passed selector
138
+
139
+ // * selector - a CSS selector for the element that should have focus
140
+
118
141
  const assignFocus = selector => {
119
142
  const element = selector && selector.nodeType === Node.ELEMENT_NODE ? selector : document.querySelector(selector);
120
143
  const focusElement = element || ActiveElement.element;
121
144
  if (focusElement && focusElement.focus) focusElement.focus();
122
145
  };
123
146
 
147
+ // Dispatches an event on the passed element
148
+
149
+ // * element - the element
150
+ // * name - the name of the event
151
+ // * detail - the event detail
152
+
124
153
  const dispatch = (element, name, detail = {}) => {
125
154
  const init = {
126
155
  bubbles: true,
127
156
  cancelable: true,
128
157
  detail: detail
129
158
  };
130
- const evt = new CustomEvent(name, init);
131
- element.dispatchEvent(evt);
159
+ const event = new CustomEvent(name, init);
160
+ element.dispatchEvent(event);
132
161
  if (window.jQuery) window.jQuery(element).trigger(name, detail);
133
162
  };
134
163
 
164
+ // Accepts an xPath query and returns the element found at that position in the DOM
165
+
135
166
  const xpathToElement = xpath => document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
136
167
 
137
- const getClassNames = names => Array(names).flat();
168
+ // Accepts an xPath query and returns all matching elements in the DOM
169
+
170
+ const xpathToElementArray = (xpath, reverse = false) => {
171
+ const snapshotList = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
172
+ const snapshots = [];
173
+ for (let i = 0; i < snapshotList.snapshotLength; i++) {
174
+ snapshots.push(snapshotList.snapshotItem(i));
175
+ }
176
+ return reverse ? snapshots.reverse() : snapshots;
177
+ };
178
+
179
+ // Return an array with the class names to be used
180
+
181
+ // * names - could be a string or an array of strings for multiple classes.
182
+
183
+ const getClassNames = names => Array.from(names).flat()
184
+ // Perform operation for either the first or all of the elements returned by CSS selector
185
+
186
+ // * operation - the instruction payload from perform
187
+ // * callback - the operation function to run for each element
188
+
189
+ ;
138
190
 
139
191
  const processElements = (operation, callback) => {
140
192
  Array.from(operation.selectAll ? operation.element : [ operation.element ]).forEach(callback);
141
193
  };
142
194
 
143
- const kebabize = str => str.split("").map(((letter, idx) => letter.toUpperCase() === letter ? `${idx !== 0 ? "-" : ""}${letter.toLowerCase()}` : letter)).join("");
195
+ // convert string to kebab-case
196
+ // most other implementations (lodash) are focused on camelCase to kebab-case
197
+ // instead, this uses word token boundaries to produce readable URL slugs and keys
198
+ // this implementation will not support Emoji or other non-ASCII characters
199
+
200
+ const kebabize = createCompounder((function(result, word, index) {
201
+ return result + (index ? "-" : "") + word.toLowerCase();
202
+ }));
203
+
204
+ function createCompounder(callback) {
205
+ return function(str) {
206
+ return words(str).reduce(callback, "");
207
+ };
208
+ }
209
+
210
+ const words = str => {
211
+ str = str == null ? "" : str;
212
+ return str.match(/([A-Z]{2,}|[0-9]+|[A-Z]?[a-z]+|[A-Z])/g) || [];
213
+ };
214
+
215
+ // Provide a standardized pipeline of checks and modifications to all operations based on provided options
216
+ // Currently skips execution if cancelled and implements an optional delay
144
217
 
145
218
  const operate = (operation, callback) => {
146
219
  if (!operation.cancel) {
@@ -150,15 +223,17 @@ const operate = (operation, callback) => {
150
223
  return false;
151
224
  };
152
225
 
226
+ // Dispatch life-cycle events with standardized naming
153
227
  const before = (target, operation) => dispatch(target, `cable-ready:before-${kebabize(operation.operation)}`, operation);
154
228
 
155
229
  const after = (target, operation) => dispatch(target, `cable-ready:after-${kebabize(operation.operation)}`, operation);
156
230
 
157
- function debounce(func, timeout) {
231
+ function debounce(fn, delay = 250) {
158
232
  let timer;
159
233
  return (...args) => {
160
- clearTimeout(timer);
161
- timer = setTimeout((() => func.apply(this, args)), timeout);
234
+ const callback = () => fn.apply(this, args);
235
+ if (timer) clearTimeout(timer);
236
+ timer = setTimeout(callback, delay);
162
237
  };
163
238
  }
164
239
 
@@ -167,6 +242,40 @@ function handleErrors(response) {
167
242
  return response;
168
243
  }
169
244
 
245
+ function safeScalar(val) {
246
+ if (val !== undefined && ![ "string", "number", "boolean" ].includes(typeof val)) console.warn(`Operation expects a string, number or boolean, but got ${val} (${typeof val})`);
247
+ return val != null ? val : "";
248
+ }
249
+
250
+ function safeString(str) {
251
+ if (str !== undefined && typeof str !== "string") console.warn(`Operation expects a string, but got ${str} (${typeof str})`);
252
+ return str != null ? String(str) : "";
253
+ }
254
+
255
+ function safeArray(arr) {
256
+ if (arr !== undefined && !Array.isArray(arr)) console.warn(`Operation expects an array, but got ${arr} (${typeof arr})`);
257
+ return arr != null ? Array.from(arr) : [];
258
+ }
259
+
260
+ function safeObject(obj) {
261
+ if (obj !== undefined && typeof obj !== "object") console.warn(`Operation expects an object, but got ${obj} (${typeof obj})`);
262
+ return obj != null ? Object(obj) : {};
263
+ }
264
+
265
+ function safeStringOrArray(elem) {
266
+ if (elem !== undefined && !Array.isArray(elem) && typeof elem !== "string") console.warn(`Operation expects an Array or a String, but got ${elem} (${typeof elem})`);
267
+ return elem == null ? "" : Array.isArray(elem) ? Array.from(elem) : String(elem);
268
+ }
269
+
270
+ function fragmentToString(fragment) {
271
+ return (new XMLSerializer).serializeToString(fragment);
272
+ }
273
+
274
+ // A proxy method to wrap a fetch call in error handling
275
+
276
+ // * url - the URL to fetch
277
+ // * additionalHeaders - an object of additional headers passed to fetch
278
+
170
279
  async function graciouslyFetch(url, additionalHeaders) {
171
280
  try {
172
281
  const response = await fetch(url, {
@@ -183,24 +292,37 @@ async function graciouslyFetch(url, additionalHeaders) {
183
292
  }
184
293
  }
185
294
 
186
- var utils = Object.freeze({
295
+ var utils = Object.freeze({
187
296
  __proto__: null,
188
- isTextInput: isTextInput,
297
+ after: after,
189
298
  assignFocus: assignFocus,
190
- dispatch: dispatch,
191
- xpathToElement: xpathToElement,
192
- getClassNames: getClassNames,
193
- processElements: processElements,
194
- operate: operate,
195
299
  before: before,
196
- after: after,
197
300
  debounce: debounce,
198
- handleErrors: handleErrors,
301
+ dispatch: dispatch,
302
+ fragmentToString: fragmentToString,
303
+ getClassNames: getClassNames,
199
304
  graciouslyFetch: graciouslyFetch,
200
- kebabize: kebabize
305
+ handleErrors: handleErrors,
306
+ isTextInput: isTextInput,
307
+ kebabize: kebabize,
308
+ operate: operate,
309
+ processElements: processElements,
310
+ safeArray: safeArray,
311
+ safeObject: safeObject,
312
+ safeScalar: safeScalar,
313
+ safeString: safeString,
314
+ safeStringOrArray: safeStringOrArray,
315
+ xpathToElement: xpathToElement,
316
+ xpathToElementArray: xpathToElementArray
201
317
  });
202
318
 
203
- const shouldMorph = operation => (fromEl, toEl) => !shouldMorphCallbacks.map((callback => typeof callback === "function" ? callback(operation, fromEl, toEl) : true)).includes(false);
319
+ // Indicates whether or not we should morph an element via onBeforeElUpdated callback
320
+ // SEE: https://github.com/patrick-steele-idem/morphdom#morphdomfromnode-tonode-options--node
321
+
322
+ const shouldMorph = operation => (fromEl, toEl) => !shouldMorphCallbacks.map((callback => typeof callback === "function" ? callback(operation, fromEl, toEl) : true)).includes(false)
323
+ // Execute any pluggable functions that modify elements after morphing via onElUpdated callback
324
+
325
+ ;
204
326
 
205
327
  const didMorph = operation => el => {
206
328
  didMorphCallbacks.forEach((callback => {
@@ -209,6 +331,8 @@ const didMorph = operation => el => {
209
331
  };
210
332
 
211
333
  const verifyNotMutable = (detail, fromEl, toEl) => {
334
+ // Skip nodes that are equal:
335
+ // https://github.com/patrick-steele-idem/morphdom#can-i-make-morphdom-blaze-through-the-dom-tree-even-faster-yes
212
336
  if (!mutableTags[fromEl.tagName] && fromEl.isEqualNode(toEl)) return false;
213
337
  return true;
214
338
  };
@@ -222,7 +346,8 @@ const verifyNotPermanent = (detail, fromEl, toEl) => {
222
346
  const {permanentAttributeName: permanentAttributeName} = detail;
223
347
  if (!permanentAttributeName) return true;
224
348
  const permanent = fromEl.closest(`[${permanentAttributeName}]`);
225
- if (!permanent && fromEl === ActiveElement.element && isTextInput(fromEl)) {
349
+ // only morph attributes on the active non-permanent text input
350
+ if (!permanent && fromEl === ActiveElement.element && isTextInput(fromEl)) {
226
351
  const ignore = {
227
352
  value: true
228
353
  };
@@ -238,24 +363,25 @@ const shouldMorphCallbacks = [ verifyNotMutable, verifyNotPermanent, verifyNotCo
238
363
 
239
364
  const didMorphCallbacks = [];
240
365
 
241
- var morph_callbacks = Object.freeze({
366
+ var morph_callbacks = Object.freeze({
242
367
  __proto__: null,
243
- shouldMorphCallbacks: shouldMorphCallbacks,
368
+ didMorph: didMorph,
244
369
  didMorphCallbacks: didMorphCallbacks,
245
370
  shouldMorph: shouldMorph,
246
- didMorph: didMorph,
247
- verifyNotMutable: verifyNotMutable,
371
+ shouldMorphCallbacks: shouldMorphCallbacks,
248
372
  verifyNotContentEditable: verifyNotContentEditable,
373
+ verifyNotMutable: verifyNotMutable,
249
374
  verifyNotPermanent: verifyNotPermanent
250
375
  });
251
376
 
252
377
  var Operations = {
378
+ // DOM Mutations
253
379
  append: operation => {
254
380
  processElements(operation, (element => {
255
381
  before(element, operation);
256
382
  operate(operation, (() => {
257
383
  const {html: html, focusSelector: focusSelector} = operation;
258
- element.insertAdjacentHTML("beforeend", html || "");
384
+ element.insertAdjacentHTML("beforeend", safeScalar(html));
259
385
  assignFocus(focusSelector);
260
386
  }));
261
387
  after(element, operation);
@@ -280,7 +406,7 @@ var Operations = {
280
406
  before(element, operation);
281
407
  operate(operation, (() => {
282
408
  const {html: html, focusSelector: focusSelector} = operation;
283
- element.innerHTML = html || "";
409
+ element.innerHTML = safeScalar(html);
284
410
  assignFocus(focusSelector);
285
411
  }));
286
412
  after(element, operation);
@@ -291,7 +417,7 @@ var Operations = {
291
417
  before(element, operation);
292
418
  operate(operation, (() => {
293
419
  const {html: html, position: position, focusSelector: focusSelector} = operation;
294
- element.insertAdjacentHTML(position || "beforeend", html || "");
420
+ element.insertAdjacentHTML(position || "beforeend", safeScalar(html));
295
421
  assignFocus(focusSelector);
296
422
  }));
297
423
  after(element, operation);
@@ -302,44 +428,23 @@ var Operations = {
302
428
  before(element, operation);
303
429
  operate(operation, (() => {
304
430
  const {text: text, position: position, focusSelector: focusSelector} = operation;
305
- element.insertAdjacentText(position || "beforeend", text || "");
431
+ element.insertAdjacentText(position || "beforeend", safeScalar(text));
306
432
  assignFocus(focusSelector);
307
433
  }));
308
434
  after(element, operation);
309
435
  }));
310
436
  },
311
- morph: operation => {
312
- processElements(operation, (element => {
313
- const {html: html} = operation;
314
- const template = document.createElement("template");
315
- template.innerHTML = String(html).trim();
316
- operation.content = template.content;
317
- const parent = element.parentElement;
318
- const ordinal = Array.from(parent.children).indexOf(element);
319
- before(element, operation);
320
- operate(operation, (() => {
321
- const {childrenOnly: childrenOnly, focusSelector: focusSelector} = operation;
322
- morphdom(element, childrenOnly ? template.content : template.innerHTML, {
323
- childrenOnly: !!childrenOnly,
324
- onBeforeElUpdated: shouldMorph(operation),
325
- onElUpdated: didMorph(operation)
326
- });
327
- assignFocus(focusSelector);
328
- }));
329
- after(parent.children[ordinal], operation);
330
- }));
331
- },
332
437
  outerHtml: operation => {
333
438
  processElements(operation, (element => {
334
439
  const parent = element.parentElement;
335
- const ordinal = Array.from(parent.children).indexOf(element);
440
+ const idx = parent && Array.from(parent.children).indexOf(element);
336
441
  before(element, operation);
337
442
  operate(operation, (() => {
338
443
  const {html: html, focusSelector: focusSelector} = operation;
339
- element.outerHTML = html || "";
444
+ element.outerHTML = safeScalar(html);
340
445
  assignFocus(focusSelector);
341
446
  }));
342
- after(parent.children[ordinal], operation);
447
+ after(parent ? parent.children[idx] : document.documentElement, operation);
343
448
  }));
344
449
  },
345
450
  prepend: operation => {
@@ -347,7 +452,7 @@ var Operations = {
347
452
  before(element, operation);
348
453
  operate(operation, (() => {
349
454
  const {html: html, focusSelector: focusSelector} = operation;
350
- element.insertAdjacentHTML("afterbegin", html || "");
455
+ element.insertAdjacentHTML("afterbegin", safeScalar(html));
351
456
  assignFocus(focusSelector);
352
457
  }));
353
458
  after(element, operation);
@@ -367,14 +472,14 @@ var Operations = {
367
472
  replace: operation => {
368
473
  processElements(operation, (element => {
369
474
  const parent = element.parentElement;
370
- const ordinal = Array.from(parent.children).indexOf(element);
475
+ const idx = parent && Array.from(parent.children).indexOf(element);
371
476
  before(element, operation);
372
477
  operate(operation, (() => {
373
478
  const {html: html, focusSelector: focusSelector} = operation;
374
- element.outerHTML = html || "";
479
+ element.outerHTML = safeScalar(html);
375
480
  assignFocus(focusSelector);
376
481
  }));
377
- after(parent.children[ordinal], operation);
482
+ after(parent ? parent.children[idx] : document.documentElement, operation);
378
483
  }));
379
484
  },
380
485
  textContent: operation => {
@@ -382,18 +487,19 @@ var Operations = {
382
487
  before(element, operation);
383
488
  operate(operation, (() => {
384
489
  const {text: text, focusSelector: focusSelector} = operation;
385
- element.textContent = text != null ? text : "";
490
+ element.textContent = safeScalar(text);
386
491
  assignFocus(focusSelector);
387
492
  }));
388
493
  after(element, operation);
389
494
  }));
390
495
  },
496
+ // Element Property Mutations
391
497
  addCssClass: operation => {
392
498
  processElements(operation, (element => {
393
499
  before(element, operation);
394
500
  operate(operation, (() => {
395
501
  const {name: name} = operation;
396
- element.classList.add(...getClassNames(name || ""));
502
+ element.classList.add(...getClassNames([ safeStringOrArray(name) ]));
397
503
  }));
398
504
  after(element, operation);
399
505
  }));
@@ -403,7 +509,7 @@ var Operations = {
403
509
  before(element, operation);
404
510
  operate(operation, (() => {
405
511
  const {name: name} = operation;
406
- element.removeAttribute(name);
512
+ element.removeAttribute(safeString(name));
407
513
  }));
408
514
  after(element, operation);
409
515
  }));
@@ -413,7 +519,8 @@ var Operations = {
413
519
  before(element, operation);
414
520
  operate(operation, (() => {
415
521
  const {name: name} = operation;
416
- element.classList.remove(...getClassNames(name));
522
+ element.classList.remove(...getClassNames([ safeStringOrArray(name) ]));
523
+ if (element.classList.length === 0) element.removeAttribute("class");
417
524
  }));
418
525
  after(element, operation);
419
526
  }));
@@ -423,7 +530,7 @@ var Operations = {
423
530
  before(element, operation);
424
531
  operate(operation, (() => {
425
532
  const {name: name, value: value} = operation;
426
- element.setAttribute(name, value || "");
533
+ element.setAttribute(safeString(name), safeScalar(value));
427
534
  }));
428
535
  after(element, operation);
429
536
  }));
@@ -433,7 +540,7 @@ var Operations = {
433
540
  before(element, operation);
434
541
  operate(operation, (() => {
435
542
  const {name: name, value: value} = operation;
436
- element.dataset[name] = value || "";
543
+ element.dataset[safeString(name)] = safeScalar(value);
437
544
  }));
438
545
  after(element, operation);
439
546
  }));
@@ -443,7 +550,7 @@ var Operations = {
443
550
  before(element, operation);
444
551
  operate(operation, (() => {
445
552
  const {name: name, value: value} = operation;
446
- if (name in element) element[name] = value || "";
553
+ if (name in element) element[safeString(name)] = safeScalar(value);
447
554
  }));
448
555
  after(element, operation);
449
556
  }));
@@ -453,7 +560,7 @@ var Operations = {
453
560
  before(element, operation);
454
561
  operate(operation, (() => {
455
562
  const {name: name, value: value} = operation;
456
- element.style[name] = value || "";
563
+ element.style[safeString(name)] = safeScalar(value);
457
564
  }));
458
565
  after(element, operation);
459
566
  }));
@@ -463,7 +570,7 @@ var Operations = {
463
570
  before(element, operation);
464
571
  operate(operation, (() => {
465
572
  const {styles: styles} = operation;
466
- for (let [name, value] of Object.entries(styles)) element.style[name] = value || "";
573
+ for (let [name, value] of Object.entries(styles)) element.style[safeString(name)] = safeScalar(value);
467
574
  }));
468
575
  after(element, operation);
469
576
  }));
@@ -473,17 +580,18 @@ var Operations = {
473
580
  before(element, operation);
474
581
  operate(operation, (() => {
475
582
  const {value: value} = operation;
476
- element.value = value || "";
583
+ element.value = safeScalar(value);
477
584
  }));
478
585
  after(element, operation);
479
586
  }));
480
587
  },
588
+ // DOM Events and Meta-Operations
481
589
  dispatchEvent: operation => {
482
590
  processElements(operation, (element => {
483
591
  before(element, operation);
484
592
  operate(operation, (() => {
485
593
  const {name: name, detail: detail} = operation;
486
- dispatch(element, name, detail);
594
+ dispatch(element, safeString(name), safeObject(detail));
487
595
  }));
488
596
  after(element, operation);
489
597
  }));
@@ -495,13 +603,22 @@ var Operations = {
495
603
  let meta = document.head.querySelector(`meta[name='${name}']`);
496
604
  if (!meta) {
497
605
  meta = document.createElement("meta");
498
- meta.name = name;
606
+ meta.name = safeString(name);
499
607
  document.head.appendChild(meta);
500
608
  }
501
- meta.content = content;
609
+ meta.content = safeScalar(content);
502
610
  }));
503
611
  after(document, operation);
504
612
  },
613
+ setTitle: operation => {
614
+ before(document, operation);
615
+ operate(operation, (() => {
616
+ const {title: title} = operation;
617
+ document.title = safeScalar(title);
618
+ }));
619
+ after(document, operation);
620
+ },
621
+ // Browser Manipulations
505
622
  clearStorage: operation => {
506
623
  before(document, operation);
507
624
  operate(operation, (() => {
@@ -523,22 +640,28 @@ var Operations = {
523
640
  before(window, operation);
524
641
  operate(operation, (() => {
525
642
  const {state: state, title: title, url: url} = operation;
526
- history.pushState(state || {}, title || "", url);
643
+ history.pushState(safeObject(state), safeString(title), safeString(url));
527
644
  }));
528
645
  after(window, operation);
529
646
  },
530
647
  redirectTo: operation => {
531
648
  before(window, operation);
532
649
  operate(operation, (() => {
533
- let {url: url, action: action} = operation;
650
+ let {url: url, action: action, turbo: turbo} = operation;
534
651
  action = action || "advance";
535
- if (window.Turbo) window.Turbo.visit(url, {
536
- action: action
537
- });
538
- if (window.Turbolinks) window.Turbolinks.visit(url, {
539
- action: action
540
- });
541
- if (!window.Turbo && !window.Turbolinks) window.location.href = url;
652
+ url = safeString(url);
653
+ if (turbo === undefined) turbo = true;
654
+ if (turbo) {
655
+ if (window.Turbo) window.Turbo.visit(url, {
656
+ action: action
657
+ });
658
+ if (window.Turbolinks) window.Turbolinks.visit(url, {
659
+ action: action
660
+ });
661
+ if (!window.Turbo && !window.Turbolinks) window.location.href = url;
662
+ } else {
663
+ window.location.href = url;
664
+ }
542
665
  }));
543
666
  after(window, operation);
544
667
  },
@@ -554,7 +677,7 @@ var Operations = {
554
677
  operate(operation, (() => {
555
678
  const {key: key, type: type} = operation;
556
679
  const storage = type === "session" ? sessionStorage : localStorage;
557
- storage.removeItem(key);
680
+ storage.removeItem(safeString(key));
558
681
  }));
559
682
  after(document, operation);
560
683
  },
@@ -562,7 +685,7 @@ var Operations = {
562
685
  before(window, operation);
563
686
  operate(operation, (() => {
564
687
  const {state: state, title: title, url: url} = operation;
565
- history.replaceState(state || {}, title || "", url);
688
+ history.replaceState(safeObject(state), safeString(title), safeString(url));
566
689
  }));
567
690
  after(window, operation);
568
691
  },
@@ -578,7 +701,7 @@ var Operations = {
578
701
  before(document, operation);
579
702
  operate(operation, (() => {
580
703
  const {cookie: cookie} = operation;
581
- document.cookie = cookie || "";
704
+ document.cookie = safeScalar(cookie);
582
705
  }));
583
706
  after(document, operation);
584
707
  },
@@ -595,15 +718,16 @@ var Operations = {
595
718
  operate(operation, (() => {
596
719
  const {key: key, value: value, type: type} = operation;
597
720
  const storage = type === "session" ? sessionStorage : localStorage;
598
- storage.setItem(key, value || "");
721
+ storage.setItem(safeString(key), safeScalar(value));
599
722
  }));
600
723
  after(document, operation);
601
724
  },
725
+ // Notifications
602
726
  consoleLog: operation => {
603
727
  before(document, operation);
604
728
  operate(operation, (() => {
605
729
  const {message: message, level: level} = operation;
606
- level && [ "warn", "info", "error" ].includes(level) ? console[level](message || "") : console.log(message || "");
730
+ level && [ "warn", "info", "error" ].includes(level) ? console[level](message) : console.log(message);
607
731
  }));
608
732
  after(document, operation);
609
733
  },
@@ -611,7 +735,7 @@ var Operations = {
611
735
  before(document, operation);
612
736
  operate(operation, (() => {
613
737
  const {data: data, columns: columns} = operation;
614
- console.table(data, columns || []);
738
+ console.table(data, safeArray(columns));
615
739
  }));
616
740
  after(document, operation);
617
741
  },
@@ -621,10 +745,32 @@ var Operations = {
621
745
  const {title: title, options: options} = operation;
622
746
  Notification.requestPermission().then((result => {
623
747
  operation.permission = result;
624
- if (result === "granted") new Notification(title || "", options);
748
+ if (result === "granted") new Notification(safeString(title), safeObject(options));
625
749
  }));
626
750
  }));
627
751
  after(document, operation);
752
+ },
753
+ // Morph operations
754
+ morph: operation => {
755
+ processElements(operation, (element => {
756
+ const {html: html} = operation;
757
+ const template = document.createElement("template");
758
+ template.innerHTML = String(safeScalar(html)).trim();
759
+ operation.content = template.content;
760
+ const parent = element.parentElement;
761
+ const idx = parent && Array.from(parent.children).indexOf(element);
762
+ before(element, operation);
763
+ operate(operation, (() => {
764
+ const {childrenOnly: childrenOnly, focusSelector: focusSelector} = operation;
765
+ morphdom(element, childrenOnly ? template.content : template.innerHTML, {
766
+ childrenOnly: !!childrenOnly,
767
+ onBeforeElUpdated: shouldMorph(operation),
768
+ onElUpdated: didMorph(operation)
769
+ });
770
+ assignFocus(focusSelector);
771
+ }));
772
+ after(parent ? parent.children[idx] : document.documentElement, operation);
773
+ }));
628
774
  }
629
775
  };
630
776
 
@@ -653,8 +799,19 @@ var OperationStore = {
653
799
  }
654
800
  };
655
801
 
802
+ let missingElement = "warn";
803
+
804
+ var MissingElement$1 = {
805
+ get behavior() {
806
+ return missingElement;
807
+ },
808
+ set(value) {
809
+ if ([ "warn", "ignore", "event", "exception" ].includes(value)) missingElement = value; else console.warn("Invalid 'onMissingElement' option. Defaulting to 'warn'.");
810
+ }
811
+ };
812
+
656
813
  const perform = (operations, options = {
657
- emitMissingElementWarnings: true
814
+ onMissingElement: MissingElement$1.behavior
658
815
  }) => {
659
816
  const batches = {};
660
817
  operations.forEach((operation => {
@@ -664,11 +821,15 @@ const perform = (operations, options = {
664
821
  const name = operation.operation;
665
822
  try {
666
823
  if (operation.selector) {
667
- operation.element = operation.xpath ? xpathToElement(operation.selector) : document[operation.selectAll ? "querySelectorAll" : "querySelector"](operation.selector);
824
+ if (operation.xpath) {
825
+ operation.element = operation.selectAll ? xpathToElementArray(operation.selector) : xpathToElement(operation.selector);
826
+ } else {
827
+ operation.element = operation.selectAll ? document.querySelectorAll(operation.selector) : document.querySelector(operation.selector);
828
+ }
668
829
  } else {
669
830
  operation.element = document;
670
831
  }
671
- if (operation.element || options.emitMissingElementWarnings) {
832
+ if (operation.element || options.onMissingElement !== "ignore") {
672
833
  ActiveElement.set(document.activeElement);
673
834
  const cableReadyOperation = OperationStore.all[name];
674
835
  if (cableReadyOperation) {
@@ -685,14 +846,31 @@ const perform = (operations, options = {
685
846
  console.error(`CableReady detected an error in ${name || "operation"}: ${e.message}. If you need to support older browsers make sure you've included the corresponding polyfills. https://docs.stimulusreflex.com/setup#polyfills-for-ie11.`);
686
847
  console.error(e);
687
848
  } else {
688
- console.warn(`CableReady ${name || "operation"} failed due to missing DOM element for selector: '${operation.selector}'`);
849
+ const warning = `CableReady ${name || ""} operation failed due to missing DOM element for selector: '${operation.selector}'`;
850
+ switch (options.onMissingElement) {
851
+ case "ignore":
852
+ break;
853
+
854
+ case "event":
855
+ dispatch(document, "cable-ready:missing-element", {
856
+ warning: warning,
857
+ operation: operation
858
+ });
859
+ break;
860
+
861
+ case "exception":
862
+ throw warning;
863
+
864
+ default:
865
+ console.warn(warning);
866
+ }
689
867
  }
690
868
  }
691
869
  }));
692
870
  };
693
871
 
694
872
  const performAsync = (operations, options = {
695
- emitMissingElementWarnings: true
873
+ onMissingElement: MissingElement$1.behavior
696
874
  }) => new Promise(((resolve, reject) => {
697
875
  try {
698
876
  resolve(perform(operations, options));
@@ -701,6 +879,34 @@ const performAsync = (operations, options = {
701
879
  }
702
880
  }));
703
881
 
882
+ class SubscribingElement extends HTMLElement {
883
+ static get tagName() {
884
+ throw new Error("Implement the tagName() getter in the inheriting class");
885
+ }
886
+ static define() {
887
+ if (!customElements.get(this.tagName)) {
888
+ customElements.define(this.tagName, this);
889
+ }
890
+ }
891
+ disconnectedCallback() {
892
+ if (this.channel) this.channel.unsubscribe();
893
+ }
894
+ createSubscription(consumer, channel, receivedCallback) {
895
+ this.channel = consumer.subscriptions.create({
896
+ channel: channel,
897
+ identifier: this.identifier
898
+ }, {
899
+ received: receivedCallback
900
+ });
901
+ }
902
+ get preview() {
903
+ return document.documentElement.hasAttribute("data-turbolinks-preview") || document.documentElement.hasAttribute("data-turbo-preview");
904
+ }
905
+ get identifier() {
906
+ return this.getAttribute("identifier");
907
+ }
908
+ }
909
+
704
910
  let consumer;
705
911
 
706
912
  const BACKOFF = [ 25, 50, 75, 100, 200, 250, 500, 800, 1e3, 2e3 ];
@@ -728,44 +934,108 @@ var CableConsumer = {
728
934
  }
729
935
  };
730
936
 
731
- class SubscribingElement extends HTMLElement {
732
- disconnectedCallback() {
733
- if (this.channel) this.channel.unsubscribe();
734
- }
735
- createSubscription(consumer, channel, receivedCallback) {
736
- this.channel = consumer.subscriptions.create({
737
- channel: channel,
738
- identifier: this.identifier
739
- }, {
740
- received: receivedCallback
741
- });
742
- }
743
- get preview() {
744
- return document.documentElement.hasAttribute("data-turbolinks-preview") || document.documentElement.hasAttribute("data-turbo-preview");
745
- }
746
- get identifier() {
747
- return this.getAttribute("identifier");
748
- }
749
- }
750
-
751
937
  class StreamFromElement extends SubscribingElement {
938
+ static get tagName() {
939
+ return "cable-ready-stream-from";
940
+ }
752
941
  async connectedCallback() {
753
942
  if (this.preview) return;
754
943
  const consumer = await CableConsumer.getConsumer();
755
944
  if (consumer) {
756
- this.createSubscription(consumer, "CableReady::Stream", this.performOperations);
945
+ this.createSubscription(consumer, "CableReady::Stream", this.performOperations.bind(this));
757
946
  } else {
758
- console.error("The `stream_from` helper cannot connect without an ActionCable consumer.\nPlease run `rails generate cable_ready:helpers` to fix this.");
947
+ console.error("The `cable_ready_stream_from` helper cannot connect. You must initialize CableReady with an Action Cable consumer.");
759
948
  }
760
949
  }
761
950
  performOperations(data) {
762
- if (data.cableReady) perform(data.operations);
951
+ if (data.cableReady) perform(data.operations, {
952
+ onMissingElement: this.onMissingElement
953
+ });
954
+ }
955
+ get onMissingElement() {
956
+ const value = this.getAttribute("missing") || MissingElement$1.behavior;
957
+ // stream_from does not support raising exceptions on missing elements because there's no way to catch them
958
+ if ([ "warn", "ignore", "event" ].includes(value)) return value; else {
959
+ console.warn("Invalid 'missing' attribute. Defaulting to 'warn'.");
960
+ return "warn";
961
+ }
763
962
  }
764
963
  }
765
964
 
965
+ let debugging = false;
966
+
967
+ var Debug = {
968
+ get enabled() {
969
+ return debugging;
970
+ },
971
+ get disabled() {
972
+ return !debugging;
973
+ },
974
+ get value() {
975
+ return debugging;
976
+ },
977
+ set(value) {
978
+ debugging = !!value;
979
+ },
980
+ set debug(value) {
981
+ debugging = !!value;
982
+ }
983
+ };
984
+
985
+ const request = (data, blocks) => {
986
+ if (Debug.disabled) return;
987
+ console.log(`↑ Updatable request affecting ${blocks.length} element(s): `, {
988
+ elements: blocks.map((b => b.element)),
989
+ identifiers: blocks.map((b => b.element.getAttribute("identifier"))),
990
+ data: data
991
+ });
992
+ };
993
+
994
+ const cancel = (timestamp, reason) => {
995
+ if (Debug.disabled) return;
996
+ const duration = new Date - timestamp;
997
+ console.log(`❌ Updatable request canceled after ${duration}ms: ${reason}`);
998
+ };
999
+
1000
+ const response = (timestamp, element, urls) => {
1001
+ if (Debug.disabled) return;
1002
+ const duration = new Date - timestamp;
1003
+ console.log(`↓ Updatable response: All URLs fetched in ${duration}ms`, {
1004
+ element: element,
1005
+ urls: urls
1006
+ });
1007
+ };
1008
+
1009
+ const morphStart = (timestamp, element) => {
1010
+ if (Debug.disabled) return;
1011
+ const duration = new Date - timestamp;
1012
+ console.log(`↻ Updatable morph: starting after ${duration}ms`, {
1013
+ element: element
1014
+ });
1015
+ };
1016
+
1017
+ const morphEnd = (timestamp, element) => {
1018
+ if (Debug.disabled) return;
1019
+ const duration = new Date - timestamp;
1020
+ console.log(`↺ Updatable morph: completed after ${duration}ms`, {
1021
+ element: element
1022
+ });
1023
+ };
1024
+
1025
+ var Log = {
1026
+ request: request,
1027
+ cancel: cancel,
1028
+ response: response,
1029
+ morphStart: morphStart,
1030
+ morphEnd: morphEnd
1031
+ };
1032
+
766
1033
  const template = `\n<style>\n :host {\n display: block;\n }\n</style>\n<slot></slot>\n`;
767
1034
 
768
1035
  class UpdatesForElement extends SubscribingElement {
1036
+ static get tagName() {
1037
+ return "cable-ready-updates-for";
1038
+ }
769
1039
  constructor() {
770
1040
  super();
771
1041
  const shadowRoot = this.attachShadow({
@@ -780,14 +1050,26 @@ class UpdatesForElement extends SubscribingElement {
780
1050
  if (consumer) {
781
1051
  this.createSubscription(consumer, "CableReady::Stream", this.update);
782
1052
  } else {
783
- console.error("The `updates-for` helper cannot connect without an ActionCable consumer.\nPlease run `rails generate cable_ready:helpers` to fix this.");
1053
+ console.error("The `cable_ready_updates_for` helper cannot connect. You must initialize CableReady with an Action Cable consumer.");
784
1054
  }
785
1055
  }
786
1056
  async update(data) {
787
- const blocks = Array.from(document.querySelectorAll(this.query), (element => new Block(element)));
788
- if (blocks[0].element !== this) return;
789
- ActiveElement.set(document.activeElement);
790
- this.html = {};
1057
+ this.lastUpdateTimestamp = new Date;
1058
+ const blocks = Array.from(document.querySelectorAll(this.query), (element => new Block(element))).filter((block => block.shouldUpdate(data)));
1059
+ Log.request(data, blocks);
1060
+ if (blocks.length === 0) {
1061
+ Log.cancel(this.lastUpdateTimestamp, "All elements filtered out");
1062
+ return;
1063
+ }
1064
+ // first <cable-ready-updates-for> element in the DOM *at any given moment* updates all of the others
1065
+ if (blocks[0].element !== this) {
1066
+ Log.cancel(this.lastUpdateTimestamp, "Update already requested");
1067
+ return;
1068
+ }
1069
+ // hold a reference to the active element so that it can be restored after the morph
1070
+ ActiveElement.set(document.activeElement);
1071
+ // store all retrieved HTML in an object keyed by URL to minimize fetch calls
1072
+ this.html = {};
791
1073
  const uniqueUrls = [ ...new Set(blocks.map((block => block.url))) ];
792
1074
  await Promise.all(uniqueUrls.map((async url => {
793
1075
  if (!this.html.hasOwnProperty(url)) {
@@ -797,14 +1079,17 @@ class UpdatesForElement extends SubscribingElement {
797
1079
  this.html[url] = await response.text();
798
1080
  }
799
1081
  })));
800
- this.index = {};
1082
+ Log.response(this.lastUpdateTimestamp, this, uniqueUrls);
1083
+ // track current block index for each URL; referred to as fragments
1084
+ this.index = {};
801
1085
  blocks.forEach((block => {
1086
+ // if the block's URL is not in the index, initialize it to 0; otherwise, increment it
802
1087
  this.index.hasOwnProperty(block.url) ? this.index[block.url]++ : this.index[block.url] = 0;
803
- block.process(data, this.html, this.index);
1088
+ block.process(data, this.html, this.index, this.lastUpdateTimestamp);
804
1089
  }));
805
1090
  }
806
1091
  get query() {
807
- return `updates-for[identifier="${this.identifier}"]`;
1092
+ return `${this.tagName}[identifier="${this.identifier}"]`;
808
1093
  }
809
1094
  get identifier() {
810
1095
  return this.getAttribute("identifier");
@@ -818,8 +1103,7 @@ class Block {
818
1103
  constructor(element) {
819
1104
  this.element = element;
820
1105
  }
821
- async process(data, html, index) {
822
- if (!this.shouldUpdate(data)) return;
1106
+ async process(data, html, index, startTimestamp) {
823
1107
  const blockIndex = index[this.url];
824
1108
  const template = document.createElement("template");
825
1109
  this.element.setAttribute("updating", "updating");
@@ -836,6 +1120,7 @@ class Block {
836
1120
  permanentAttributeName: "data-ignore-updates"
837
1121
  };
838
1122
  dispatch(this.element, "cable-ready:before-update", operation);
1123
+ Log.morphStart(startTimestamp, this.element);
839
1124
  morphdom(this.element, fragments[blockIndex], {
840
1125
  childrenOnly: true,
841
1126
  onBeforeElUpdated: shouldMorph(operation),
@@ -845,6 +1130,7 @@ class Block {
845
1130
  assignFocus(operation.focusSelector);
846
1131
  }
847
1132
  });
1133
+ Log.morphEnd(startTimestamp, this.element);
848
1134
  }
849
1135
  async resolveTurboFrames(documentFragment) {
850
1136
  const reloadingTurboFrames = [ ...documentFragment.querySelectorAll('turbo-frame[src]:not([loading="lazy"])') ];
@@ -855,19 +1141,26 @@ class Block {
855
1141
  });
856
1142
  const frameTemplate = document.createElement("template");
857
1143
  frameTemplate.innerHTML = await frameResponse.text();
858
- await this.resolveTurboFrames(frameTemplate.content);
859
- documentFragment.querySelector(`turbo-frame#${frame.id}`).innerHTML = String(frameTemplate.content.querySelector(`turbo-frame#${frame.id}`).innerHTML).trim();
1144
+ // recurse here to get all nested eager loaded frames
1145
+ await this.resolveTurboFrames(frameTemplate.content);
1146
+ const selector = `turbo-frame#${frame.id}`;
1147
+ const frameContent = frameTemplate.content.querySelector(selector);
1148
+ const content = frameContent ? frameContent.innerHTML.trim() : "";
1149
+ documentFragment.querySelector(selector).innerHTML = content;
860
1150
  resolve();
861
1151
  })))));
862
1152
  }
863
1153
  shouldUpdate(data) {
1154
+ // if everything that could prevent an update is false, update this block
864
1155
  return !this.ignoresInnerUpdates && this.hasChangesSelectedForUpdate(data);
865
1156
  }
866
1157
  hasChangesSelectedForUpdate(data) {
1158
+ // if there's an only attribute, only update if at least one of the attributes changed is in the allow list
867
1159
  const only = this.element.getAttribute("only");
868
1160
  return !(only && data.changed && !only.split(" ").some((attribute => data.changed.includes(attribute))));
869
1161
  }
870
1162
  get ignoresInnerUpdates() {
1163
+ // don't update during a Reflex or Turbolinks redraw
871
1164
  return this.element.hasAttribute("ignore-inner-updates") && this.element.hasAttribute("performing-inner-update");
872
1165
  }
873
1166
  get url() {
@@ -898,10 +1191,23 @@ const registerInnerUpdates = () => {
898
1191
  recursiveUnmarkUpdatesForElements(event.target);
899
1192
  }));
900
1193
  }));
1194
+ document.addEventListener("turbo-boost:command:start", (event => {
1195
+ recursiveMarkUpdatesForElements(event.target);
1196
+ }));
1197
+ document.addEventListener("turbo-boost:command:finish", (event => {
1198
+ setTimeout((() => {
1199
+ recursiveUnmarkUpdatesForElements(event.target);
1200
+ }));
1201
+ }));
1202
+ document.addEventListener("turbo-boost:command:error", (event => {
1203
+ setTimeout((() => {
1204
+ recursiveUnmarkUpdatesForElements(event.target);
1205
+ }));
1206
+ }));
901
1207
  };
902
1208
 
903
1209
  const recursiveMarkUpdatesForElements = leaf => {
904
- const closestUpdatesFor = leaf && leaf.parentElement.closest("updates-for");
1210
+ const closestUpdatesFor = leaf && leaf.parentElement && leaf.parentElement.closest("cable-ready-updates-for");
905
1211
  if (closestUpdatesFor) {
906
1212
  closestUpdatesFor.setAttribute("performing-inner-update", "");
907
1213
  recursiveMarkUpdatesForElements(closestUpdatesFor);
@@ -909,27 +1215,31 @@ const recursiveMarkUpdatesForElements = leaf => {
909
1215
  };
910
1216
 
911
1217
  const recursiveUnmarkUpdatesForElements = leaf => {
912
- const closestUpdatesFor = leaf && leaf.parentElement.closest("updates-for");
1218
+ const closestUpdatesFor = leaf && leaf.parentElement && leaf.parentElement.closest("cable-ready-updates-for");
913
1219
  if (closestUpdatesFor) {
914
1220
  closestUpdatesFor.removeAttribute("performing-inner-update");
915
1221
  recursiveUnmarkUpdatesForElements(closestUpdatesFor);
916
1222
  }
917
1223
  };
918
1224
 
919
- const initialize = (initializeOptions = {}) => {
920
- const {consumer: consumer} = initializeOptions;
1225
+ const defineElements = () => {
921
1226
  registerInnerUpdates();
1227
+ StreamFromElement.define();
1228
+ UpdatesForElement.define();
1229
+ };
1230
+
1231
+ const initialize = (initializeOptions = {}) => {
1232
+ const {consumer: consumer, onMissingElement: onMissingElement, debug: debug} = initializeOptions;
1233
+ Debug.set(!!debug);
922
1234
  if (consumer) {
923
1235
  CableConsumer.setConsumer(consumer);
924
1236
  } else {
925
1237
  console.error("CableReady requires a reference to your Action Cable `consumer` for its helpers to function.\nEnsure that you have imported the `CableReady` package as well as `consumer` from your `channels` folder, then call `CableReady.initialize({ consumer })`.");
926
1238
  }
927
- if (!customElements.get("stream-from")) {
928
- customElements.define("stream-from", StreamFromElement);
929
- }
930
- if (!customElements.get("updates-for")) {
931
- customElements.define("updates-for", UpdatesForElement);
1239
+ if (onMissingElement) {
1240
+ MissingElement.set(onMissingElement);
932
1241
  }
1242
+ defineElements();
933
1243
  };
934
1244
 
935
1245
  const global = {