187 | <% end %>
188 | ```
189 |
190 | Internally, this works the same as [bypassing futurism in tests](#testing)
191 |
192 |
193 | ### Broadcast Partials Individually
194 | Futurism's default behavior is to `broadcast` partials as they are generated in batches:
195 |
196 | On the client side, `IntersectionObserver` events are triggered in a debounced fashion, so several `render`s are performed on the server for each of those events. By default, futurism will group those to a single `broadcast` call (to save server CPU time).
197 |
198 | For collections, however, you can opt into individual broadcasts by specifying `broadcast_each: true` in your helper usage:
199 |
200 | ```erb
201 | <%= futurize @posts, broadcast_each: true, extends: :tr do %>
202 |
203 | <% end %>
204 | ```
205 |
206 | ### Contextual Placeholder Arguments
207 |
208 | For individual models or arbitrary collections, you can pass `record` and `index` to the placeholder block as arguments:
209 |
210 | ```erb
211 | <%= futurize @post, extends: :div do |post| %>
212 |
<%= post.title %>
213 | <% end %>
214 | ```
215 |
216 | ```erb
217 | <%= futurize @posts, extends: :tr do |post, index| %>
218 |
<%= index + 1 %> | <%= post.title %> |
219 | <% end %>
220 | ```
221 |
222 | ```erb
223 | <%= futurize partial: "users/user", collection: users, extends: "tr" do |user, index| %>
224 |
<%= index + 1 %> | <%= user.name %> |
225 | <% end >
226 | ```
227 |
228 | ## Events
229 |
230 | Once your futurize element has been rendered, the `futurism:appeared` custom event will be called.
231 |
232 | ## Instrumentation
233 |
234 | Futurism includes support for instrumenting rendering events.
235 |
236 | To enable ActiveSupport notifications, use the `instrumentation` option:
237 |
238 | ```ruby
239 | Futurism.instrumentation = true
240 | ```
241 |
242 | Then subscribe to the `render.futurism` event:
243 |
244 | ```ruby
245 | ActiveSupport::Notifications.subscribe("render.futurism") do |*args|
246 | event = ActiveSupport::Notifications::Event.new(*args)
247 | event.name # => "render.futurism"
248 | event.payload[:channel] # => "Futurism::Channel" # ActionCable channel to broadcast
249 | event.payload[:controller] # => "posts" # The controller that invokes `futurize` call
250 | event.payload[:action] # => "show" # The action that invokes `futurize` call
251 | event.payload[:partial] # => "posts/card" # The partial that was rendered
252 | end
253 | ```
254 |
255 | This is useful for performance monitoring, specifically for tracking the source of `futurize` calls.
256 |
257 | ## Installation
258 | Add this line to your application's Gemfile:
259 |
260 | ```ruby
261 | gem 'futurism'
262 | ```
263 |
264 | And then execute:
265 | ```bash
266 | $ bundle
267 | ```
268 |
269 | To copy over the javascript files to your application, run
270 |
271 | ```bash
272 | $ bin/rails futurism:install
273 | ```
274 |
275 | **! Note that the installer will run `yarn add @stimulus_reflex/futurism` for you !**
276 |
277 | ### Manual Installation
278 | After `bundle`, install the Javascript library:
279 |
280 | There are a few ways to install the Futurism JavaScript client, depending on your application setup.
281 |
282 | #### ESBuild / Webpacker
283 |
284 | ```sh
285 | yarn add @stimulus_reflex/futurism
286 | ```
287 |
288 | #### Import maps:
289 |
290 | ```ruby
291 | # config/importmap.rb
292 | # ...
293 | pin '@stimulus_reflex/futurism', to: 'futurism.min.js', preload: true
294 | ```
295 |
296 | #### Rails Asset pipeline (Sprockets):
297 |
298 | ```html+erb
299 |
300 | <%= javascript_include_tag "futurism.umd.min.js", "data-turbo-track": "reload" %>
301 | ```
302 |
303 | In your `app/javascript/channels/index.js`, add the following
304 |
305 | ```js
306 | import * as Futurism from '@stimulus_reflex/futurism'
307 |
308 | import consumer from './consumer'
309 |
310 | Futurism.initializeElements()
311 | Futurism.createSubscription(consumer)
312 | ```
313 |
314 | ## Authentication
315 | For authentication, you can rely on ActionCable identifiers, for example, if you use Devise:
316 |
317 | ```ruby
318 | module ApplicationCable
319 | class Connection < ActionCable::Connection::Base
320 | identified_by :current_user
321 |
322 | def connect
323 | self.current_user = env["warden"].user || reject_unauthorized_connection
324 | end
325 | end
326 | end
327 | ```
328 |
329 | The [Stimulus Reflex Docs](https://docs.stimulusreflex.com/authentication) have an excellent section about all sorts of authentication.
330 |
331 | ## Testing
332 | In Rails system tests there is a chance that flaky errors will occur due to Capybara not waiting for the placeholder elements to be replaced. To overcome this, add the flag
333 |
334 | ```ruby
335 | Futurism.skip_in_test = true
336 | ```
337 |
338 | to an initializer, for example `config/initializers/futurism.rb`.
339 |
340 | ## Gotchas
341 |
342 | ### ActiveStorage URLs aren't correct in development
343 |
344 | Out of the box, Rails will prefix generated urls with `http://example.org` rather than `http://localhost`, much like ActionMailer. To amend this, add
345 |
346 | ```ruby
347 | # config/environments/development.rb
348 | config.action_controller.default_url_options = {host: "localhost", port: 3000}
349 |
350 | # config/environments/production.rb
351 | config.action_controller.default_url_options = {host: "mysite.com"}
352 | ```
353 |
354 | to your environments.
355 |
356 | ### Choosing the parent for Futurism::Channel
357 |
358 | By default Futurism::CHannel will inherit from ApplicationCable::Channel, you can change this by setting
359 |
360 | ```ruby
361 | Futurism.configure do |config|
362 | config.parent_channel = "CustomFuturismChannel"
363 | end
364 |
365 | ```
366 | in config/initializers.
367 |
368 | ## Contributing
369 |
370 | ### Get local environment setup
371 |
372 | Below are a set of instructions that may help you get a local development environment working
373 |
374 | ```shell
375 | # Get the gem/npm package source locally
376 | git clone futurism
377 | cd futurism/javascript
378 | yarn install # install all of the npm package's dependencies
379 | yarn link # set the local machine's futurism npm package's lookup to this local path
380 |
381 | # Setup a sample project, use the information below directly or use your own project
382 | git clone https://github.com/leastbad/stimulus_reflex_harness.git
383 | cd stimulus_reflex_harness
384 | git checkout futurism
385 | # Edit Gemfile to point point to local gem (e.g. `gem "futurism", path: "../futurism"`)
386 | # yarn link @stimulus_reflex/futurism
387 |
388 |
389 | # Do your work, Submit PR, Profit!
390 |
391 |
392 | # To stop using your local version of futurism
393 | # change your Gemfile back to the published (e.g. `gem "futurism"`)
394 | cd path/to/futurism/javascript
395 | # Stop using the local npm package
396 | yarn unlink
397 |
398 | # Instruct your project to reinstall the published version of the npm package
399 | cd path/to/project
400 | yarn install --force
401 | ```
402 |
403 | ### 📦 Releasing
404 |
405 | 1. Make sure that you run `yarn` and `bundle` to pick up the latest.
406 | 2. Bump version number at `lib/futurism/version.rb`. Pre-release versions use `.preN`
407 | 3. Run `rake build` and `yarn build`
408 | 4. Commit and push changes to github `git commit -m "Bump version to x.x.x"`
409 | 5. Run `rake release`
410 | 6. Run `yarn publish --no-git-tag-version`
411 | 7. Yarn will prompt you for the new version. Pre-release versions use `-preN`
412 | 8. Commit and push changes to GitHub
413 | 9. Create a new release on GitHub ([here](https://github.com/stimulusreflex/futurism/releases)) and generate the changelog for the stable release for it
414 |
415 | ## License
416 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
417 |
418 | ## Contributors ✨
419 |
420 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
421 |
422 |
423 |
424 |
425 |
448 |
449 |
450 |
451 |
452 |
453 |
454 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
455 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | begin
2 | require "bundler/setup"
3 | rescue LoadError
4 | puts "You must `gem install bundler` and `bundle install` to run rake tasks"
5 | end
6 |
7 | require "rdoc/task"
8 |
9 | RDoc::Task.new(:rdoc) do |rdoc|
10 | rdoc.rdoc_dir = "rdoc"
11 | rdoc.title = "Futurism"
12 | rdoc.options << "--line-numbers"
13 | rdoc.rdoc_files.include("README.md")
14 | rdoc.rdoc_files.include("lib/**/*.rb")
15 | end
16 |
17 | load "rails/tasks/statistics.rake"
18 |
19 | require "bundler/gem_tasks"
20 |
21 | require "rake/testtask"
22 |
23 | Rake::TestTask.new(:test) do |t|
24 | t.libs << "test"
25 | t.pattern = "test/**/*_test.rb"
26 | t.verbose = false
27 | end
28 |
29 | task default: :test
30 |
--------------------------------------------------------------------------------
/app/assets/javascripts/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stimulusreflex/futurism/17ff67492c4404ffd19d834691e59b08314cfc51/app/assets/javascripts/.keep
--------------------------------------------------------------------------------
/app/channels/futurism/channel.rb:
--------------------------------------------------------------------------------
1 | module Futurism
2 | class Channel < Futurism.configuration.parent_channel.constantize
3 | include CableReady::Broadcaster
4 |
5 | def stream_name
6 | ids = connection.identifiers.map { |identifier| send(identifier).try(:id) || send(identifier) }
7 | [
8 | params[:channel],
9 | ids.select(&:present?).join(";")
10 | ].select(&:present?).join(":")
11 | end
12 |
13 | def subscribed
14 | stream_from stream_name
15 | end
16 |
17 | def receive(data)
18 | resources = data.fetch_values("signed_params", "sgids", "signed_controllers", "urls", "broadcast_each") { |_key| Array.new(data["signed_params"].length, nil) }.transpose
19 |
20 | resolver = Resolver::Resources.new(resource_definitions: resources, connection: connection, params: @params)
21 | resolver.resolve do |selector, html, broadcast_each|
22 | cable_ready[stream_name].outer_html(
23 | selector: selector,
24 | html: html
25 | )
26 |
27 | cable_ready.broadcast if broadcast_each
28 | end
29 |
30 | cable_ready.broadcast
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # This command will automatically be run when you run "rails" with Rails gems
3 | # installed from the root of your application.
4 |
5 | ENGINE_ROOT = File.expand_path('..', __dir__)
6 | ENGINE_PATH = File.expand_path('../lib/futurism/engine', __dir__)
7 | APP_PATH = File.expand_path('../test/dummy/config/application', __dir__)
8 |
9 | # Set up gems listed in the Gemfile.
10 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
11 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
12 |
13 | require "rails"
14 | # Pick the frameworks you want:
15 | require "active_model/railtie"
16 | require "active_job/railtie"
17 | # require "active_record/railtie"
18 | # require "active_storage/engine"
19 | require "action_controller/railtie"
20 | # require "action_mailer/railtie"
21 | require "action_view/railtie"
22 | require "action_cable/engine"
23 | # require "sprockets/railtie"
24 | require "rails/test_unit/railtie"
25 | require 'rails/engine/commands'
26 |
--------------------------------------------------------------------------------
/bin/standardize:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | bundle exec standardrb --fix
4 |
5 | npx prettier-standard lib/templates/**/*.js
6 |
--------------------------------------------------------------------------------
/bin/test:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | $: << File.expand_path("../test", __dir__)
3 |
4 | require "bundler/setup"
5 | require "rails/plugin/test"
6 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | Rails.application.routes.draw do
2 | end
3 |
--------------------------------------------------------------------------------
/dist/.keep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stimulusreflex/futurism/17ff67492c4404ffd19d834691e59b08314cfc51/dist/.keep
--------------------------------------------------------------------------------
/futurism.gemspec:
--------------------------------------------------------------------------------
1 | $:.push File.expand_path("lib", __dir__)
2 |
3 | # Maintain your gem's version:
4 | require "futurism/version"
5 |
6 | # Describe your gem and declare its dependencies:
7 | Gem::Specification.new do |spec|
8 | spec.name = "futurism"
9 | spec.version = Futurism::VERSION
10 | spec.authors = ["Julian Rubisch"]
11 | spec.email = ["julian@julianrubisch.at"]
12 | spec.homepage = "https://github.com/stimulusreflex/futurism"
13 | spec.summary = "Lazy-load Rails partials via CableReady"
14 | spec.description = "Uses custom html elements with attached IntersectionObserver to automatically lazy load partials via websockets"
15 | spec.license = "MIT"
16 |
17 | spec.files = Dir[
18 | "lib/**/*.rb",
19 | "lib/**/*.rake",
20 | "app/**/*.rb",
21 | "app/assets/javascripts/*",
22 | "bin/*",
23 | "[A-Z]*"
24 | ]
25 |
26 | spec.add_development_dependency "appraisal"
27 | spec.add_development_dependency "bundler", "~> 2.0"
28 | spec.add_development_dependency "rake", "~> 13.0"
29 | spec.add_development_dependency "nokogiri"
30 | spec.add_development_dependency "standardrb"
31 | spec.add_development_dependency "sqlite3"
32 |
33 | spec.add_dependency "rack", ">= 2", "< 4"
34 |
35 | spec.add_dependency "rails", ">= 5.2"
36 | spec.add_dependency "cable_ready", ">= 5.0"
37 | end
38 |
--------------------------------------------------------------------------------
/gemfiles/rails_5_2.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "appraisal", branch: "fix-bundle-env", git: "https://github.com/excid3/appraisal.git"
6 | gem "rails", "~> 5.2"
7 | gem "sqlite3", "~> 1.3", "< 1.4"
8 | gem "action-cable-testing"
9 |
10 | gemspec path: "../"
11 |
--------------------------------------------------------------------------------
/gemfiles/rails_6_0.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "rails", "~> 6.0"
6 | gem "sqlite3", "~> 1.4"
7 |
8 | gemspec path: "../"
9 |
--------------------------------------------------------------------------------
/gemfiles/rails_6_1.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "rails", "~> 6.1"
6 | gem "sqlite3", "~> 1.4"
7 |
8 | gemspec path: "../"
9 |
--------------------------------------------------------------------------------
/gemfiles/rails_7_0.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "rails", "7.0.8"
6 | gem "sqlite3", "~> 1.4"
7 |
8 | gemspec path: "../"
9 |
--------------------------------------------------------------------------------
/gemfiles/rails_7_1.gemfile:
--------------------------------------------------------------------------------
1 | # This file was generated by Appraisal
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "rails", "7.1.3"
6 | gem "sqlite3", "~> 1.4"
7 |
8 | gemspec path: "../"
9 |
--------------------------------------------------------------------------------
/javascript/elements/futurism_element.js:
--------------------------------------------------------------------------------
1 | /* global HTMLElement */
2 |
3 | import {
4 | extendElementWithIntersectionObserver,
5 | extendElementWithEagerLoading
6 | } from './futurism_utils'
7 |
8 | export default class FuturismElement extends HTMLElement {
9 | connectedCallback () {
10 | extendElementWithIntersectionObserver(this)
11 | extendElementWithEagerLoading(this)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/javascript/elements/futurism_li.js:
--------------------------------------------------------------------------------
1 | /* global HTMLLIElement */
2 |
3 | import {
4 | extendElementWithIntersectionObserver,
5 | extendElementWithEagerLoading
6 | } from './futurism_utils'
7 |
8 | export default class FuturismLI extends HTMLLIElement {
9 | connectedCallback () {
10 | extendElementWithIntersectionObserver(this)
11 | extendElementWithEagerLoading(this)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/javascript/elements/futurism_table_row.js:
--------------------------------------------------------------------------------
1 | /* global HTMLTableRowElement */
2 |
3 | import {
4 | extendElementWithIntersectionObserver,
5 | extendElementWithEagerLoading
6 | } from './futurism_utils'
7 |
8 | export default class FuturismTableRow extends HTMLTableRowElement {
9 | connectedCallback () {
10 | extendElementWithIntersectionObserver(this)
11 | extendElementWithEagerLoading(this)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/javascript/elements/futurism_utils.js:
--------------------------------------------------------------------------------
1 | /* global IntersectionObserver, CustomEvent, setTimeout */
2 |
3 | const dispatchAppearEvent = (entry, observer = null) => {
4 | if (!window.Futurism?.subscription) {
5 | return () => {
6 | setTimeout(() => dispatchAppearEvent(entry, observer)(), 1)
7 | }
8 | }
9 |
10 | const target = entry.target ? entry.target : entry
11 |
12 | const evt = new CustomEvent('futurism:appear', {
13 | bubbles: true,
14 | detail: {
15 | target,
16 | observer
17 | }
18 | })
19 |
20 | return () => {
21 | target.dispatchEvent(evt)
22 | }
23 | }
24 |
25 | // from https://advancedweb.hu/how-to-implement-an-exponential-backoff-retry-strategy-in-javascript/#rejection-based-retrying
26 | const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
27 |
28 | const callWithRetry = async (fn, depth = 0) => {
29 | try {
30 | return await fn()
31 | } catch (e) {
32 | if (depth > 10) {
33 | throw e
34 | }
35 | await wait(1.15 ** depth * 2000)
36 |
37 | return callWithRetry(fn, depth + 1)
38 | }
39 | }
40 |
41 | const observerCallback = (entries, observer) => {
42 | entries.forEach(async entry => {
43 | if (!entry.isIntersecting) return
44 |
45 | await callWithRetry(dispatchAppearEvent(entry, observer))
46 | })
47 | }
48 |
49 | export const extendElementWithIntersectionObserver = element => {
50 | Object.assign(element, {
51 | observer: new IntersectionObserver(observerCallback.bind(element), {})
52 | })
53 |
54 | if (!element.hasAttribute('keep')) {
55 | element.observer.observe(element)
56 | }
57 | }
58 |
59 | export const extendElementWithEagerLoading = element => {
60 | if (element.dataset.eager === 'true') {
61 | if (element.observer) element.observer.disconnect()
62 | callWithRetry(dispatchAppearEvent(element))
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/javascript/elements/index.js:
--------------------------------------------------------------------------------
1 | /* global customElements, sessionStorage */
2 |
3 | import FuturismElement from './futurism_element'
4 | import FuturismTableRow from './futurism_table_row'
5 | import FuturismLI from './futurism_li'
6 |
7 | import { sha256 } from '../utils/crypto'
8 |
9 | const polyfillCustomElements = () => {
10 | const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
11 |
12 | if (customElements) {
13 | if (isSafari) {
14 | document.write(
15 | '