Skip to content

Commit

Permalink
Refactor SR client package and introduce the Reflex class (#592)
Browse files Browse the repository at this point in the history
* created Stimulus.app

* elementToXPath supports html element

* reflexes Proxy

* dispatchLifecycleEvent converted to bind reflex

* top level bind doesn't work with rollup

* no need for finalStage concept anymore

* added queued and delivered stages to templates

* simplify lifecycle method signatures

* removed action cable specific events

* simplified callbacks module

* renaming reflex body param to error

* reworked client logging to operate on reflex params

* extract duration and progress from log functions

* pluggable transport mechanisms!

* changed mode to plugin

* changed forbidden reflexes to reject promise

* return error string to forbidden promise

* revert forbidden from reject to resolve

* tweaks to support non-isolation mode

* touch up comments in callback.js

* added TODO comment wrt resolveLate

* refactored reflex_store.js to reflexes.js, received() to process.js

* server side changes to deprecate reflex_id in favor of id

* client side modifications to rename reflexId to id

* localReflexControllers can import Stimulus

* revert #597 😞

* added lifecycle accessor to reflex instance
  • Loading branch information
leastbad committed Sep 14, 2022
1 parent 17fd7c9 commit f166233
Show file tree
Hide file tree
Showing 33 changed files with 804 additions and 780 deletions.
10 changes: 5 additions & 5 deletions app/channels/stimulus_reflex/channel.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ class StimulusReflex::Channel < StimulusReflex.configuration.parent_channel.cons
attr_reader :reflex_data

def stream_name
[params[:channel], connection.connection_identifier].join(":")
[params[:channel], connection.connection_identifier].reject(&:blank?).join(":")
end

def subscribed
Expand All @@ -25,7 +25,7 @@ def receive(data)
if reflex
reflex.rescue_with_handler(exception)
reflex.logger&.error error_message
reflex.broadcast_error data: data, body: "#{exception} #{exception.backtrace.first.split(":in ")[0] if Rails.env.development?}"
reflex.broadcast_error data: data, error: "#{exception} #{exception.backtrace.first.split(":in ")[0] if Rails.env.development?}"
else
if exception.is_a? StimulusReflex::Reflex::VersionMismatchError
mismatch = "Reflex failed due to stimulus_reflex gem/NPM package version mismatch. Package versions must match exactly.\nNote that if you are using pre-release builds, gems use the \"x.y.z.preN\" version format, while NPM packages use \"x.y.z-preN\".\n\nstimulus_reflex gem: #{StimulusReflex::VERSION}\nstimulus_reflex NPM: #{data["version"]}"
Expand All @@ -36,7 +36,7 @@ def receive(data)
CableReady::Channels.instance[stream_name].console_log(
message: mismatch,
level: "error",
reflex_id: data["reflexId"]
id: data["id"]
).broadcast
end

Expand All @@ -48,7 +48,7 @@ def receive(data)
StimulusReflex.config.logger.error error_message
end

if body.to_s.include? "No route matches"
if error_message.to_s.include? "No route matches"
initializer_path = Rails.root.join("config", "initializers", "stimulus_reflex.rb")

StimulusReflex.config.logger.warn <<~NOTE
Expand Down Expand Up @@ -82,7 +82,7 @@ def receive(data)
rescue => exception
reflex.rescue_with_handler(exception)
error = exception_with_backtrace(exception)
reflex.broadcast_error data: data, body: "#{exception} #{exception.backtrace.first.split(":in ")[0] if Rails.env.development?}"
reflex.broadcast_error data: data, error: "#{exception} #{exception.backtrace.first.split(":in ")[0] if Rails.env.development?}"
reflex.logger&.error "\e[31mReflex failed to re-render: #{error[:message]} [#{reflex_data.url}]\e[0m\n#{error[:stack]}"
end
end
Expand Down
10 changes: 10 additions & 0 deletions javascript/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
let app = {}

export default {
get app () {
return app
},
set (application) {
app = application
}
}
188 changes: 79 additions & 109 deletions javascript/callbacks.js
Original file line number Diff line number Diff line change
@@ -1,193 +1,163 @@
import CableReady from 'cable_ready'

import Log from './log'

import { reflexes } from './reflex_store'
import { reflexes } from './reflexes'
import { dispatchLifecycleEvent } from './lifecycle'
import { XPathToElement } from './utils'

const beforeDOMUpdate = event => {
const { stimulusReflex, payload } = event.detail || {}
const { stimulusReflex } = event.detail || {}
if (!stimulusReflex) return
const { reflexId, xpathElement, xpathController } = stimulusReflex
const controllerElement = XPathToElement(xpathController)
const reflexElement = XPathToElement(xpathElement)
const reflex = reflexes[reflexId]
const { promise } = reflex
const reflex = reflexes[stimulusReflex.id]

reflex.pendingOperations--

if (reflex.pendingOperations > 0) return

// TODO: remove in v4 - always resolve late
if (!stimulusReflex.resolveLate)
setTimeout(() =>
promise.resolve({
element: reflexElement,
reflex.promise.resolve({
element: reflex.element,
event,
data: promise.data,
payload,
reflexId,
data: reflex.data,
payload: reflex.payload,
id: reflex.id,
toString: () => ''
})
)
// END TODO: remove

setTimeout(() =>
dispatchLifecycleEvent(
'success',
reflexElement,
controllerElement,
reflexId,
payload
)
)
setTimeout(() => dispatchLifecycleEvent(reflex, 'success'))
}

const afterDOMUpdate = event => {
const { stimulusReflex, payload } = event.detail || {}
const { stimulusReflex } = event.detail || {}
if (!stimulusReflex) return
const { reflexId, xpathElement, xpathController } = stimulusReflex
const controllerElement = XPathToElement(xpathController)
const reflexElement = XPathToElement(xpathElement)
const reflex = reflexes[reflexId]
const { promise } = reflex
const reflex = reflexes[stimulusReflex.id]

reflex.completedOperations++
reflex.selector = event.detail.selector
reflex.morph = event.detail.stimulusReflex.morph
reflex.operation = event.type
.split(':')[1]
.split('-')
.slice(1)
.join('_')

Log.success(event, false)
Log.success(reflex)

if (reflex.completedOperations < reflex.totalOperations) return

// TODO: v4 always resolve late (remove if)
// TODO: v4 simplify to {reflex, toString}
if (stimulusReflex.resolveLate)
setTimeout(() =>
promise.resolve({
element: reflexElement,
reflex.promise.resolve({
element: reflex.element,
event,
data: promise.data,
payload,
reflexId,
data: reflex.data,
payload: reflex.payload,
id: reflex.id,
toString: () => ''
})
)

setTimeout(() =>
dispatchLifecycleEvent(
'finalize',
reflexElement,
controllerElement,
reflexId,
payload
)
)
setTimeout(() => dispatchLifecycleEvent(reflex, 'finalize'))

if (reflex.piggybackOperations.length)
CableReady.perform(reflex.piggybackOperations)
}

const routeReflexEvent = event => {
const { stimulusReflex, payload, name, body } = event.detail || {}
const { stimulusReflex, name } = event.detail || {}
const eventType = name.split('-')[2]

const eventTypes = {
nothing: nothing,
halted: halted,
forbidden: forbidden,
error: error
}
const eventTypes = { nothing, halted, forbidden, error }

if (!stimulusReflex || !Object.keys(eventTypes).includes(eventType)) return

const { reflexId, xpathElement, xpathController } = stimulusReflex
const reflexElement = XPathToElement(xpathElement)
const controllerElement = XPathToElement(xpathController)
const reflex = reflexes[reflexId]
const { promise } = reflex

if (controllerElement) {
controllerElement.reflexError = controllerElement.reflexError || {}
if (eventType === 'error') controllerElement.reflexError[reflexId] = body
}
const reflex = reflexes[stimulusReflex.id]
reflex.completedOperations++
reflex.pendingOperations--
reflex.selector = event.detail.selector
reflex.morph = event.detail.stimulusReflex.morph
reflex.operation = event.type
.split(':')[1]
.split('-')
.slice(1)
.join('_')
if (eventType === 'error') reflex.error = event.detail.error

eventTypes[eventType](event, payload, promise, reflex, reflexElement)
eventTypes[eventType](reflex, event)

setTimeout(() =>
dispatchLifecycleEvent(
eventType,
reflexElement,
controllerElement,
reflexId,
payload
)
)
setTimeout(() => dispatchLifecycleEvent(reflex, eventType))

if (reflex.piggybackOperations.length)
CableReady.perform(reflex.piggybackOperations)
}

const nothing = (event, payload, promise, reflex, reflexElement) => {
reflex.finalStage = 'after'

Log.success(event)
const nothing = (reflex, event) => {
Log.success(reflex)

// TODO: v4 simplify to {reflex, toString}
setTimeout(() =>
promise.resolve({
data: promise.data,
element: reflexElement,
reflex.promise.resolve({
data: reflex.data,
element: reflex.element,
event,
payload,
reflexId: promise.data.reflexId,
payload: reflex.payload,
id: reflex.id,
toString: () => ''
})
)
}

const halted = (event, payload, promise, reflex, reflexElement) => {
reflex.finalStage = 'halted'

Log.halted(event)
const halted = (reflex, event) => {
Log.halted(reflex, event)

// TODO: v4 simplify to {reflex, toString}
setTimeout(() =>
promise.resolve({
data: promise.data,
element: reflexElement,
reflex.promise.resolve({
data: reflex.data,
element: reflex.element,
event,
payload,
reflexId: promise.data.reflexId,
payload: reflex.payload,
id: reflex.id,
toString: () => ''
})
)
}

const forbidden = (event, payload, promise, reflex, reflexElement) => {
reflex.finalStage = 'forbidden'

Log.forbidden(event)
const forbidden = (reflex, event) => {
Log.forbidden(reflex, event)

// TODO: v4 simplify to {reflex, toString}
setTimeout(() =>
promise.resolve({
data: promise.data,
element: reflexElement,
reflex.promise.resolve({
data: reflex.data,
element: reflex.element,
event,
payload,
reflexId: promise.data.reflexId,
payload: reflex.payload,
id: reflex.id,
toString: () => ''
})
)
}

const error = (event, payload, promise, reflex, reflexElement) => {
reflex.finalStage = 'after'

Log.error(event)
const error = (reflex, event) => {
Log.error(reflex, event)

// TODO: v4 simplify to {reflex, toString}
// TODO: v4 convert to resolve?
setTimeout(() =>
promise.reject({
data: promise.data,
element: reflexElement,
reflex.promise.reject({
data: reflex.data,
element: reflex.element,
event,
payload,
reflexId: promise.data.reflexId,
error: event.detail.body,
toString: () => event.detail.body
payload: reflex.payload,
id: reflex.id,
error: reflex.error,
toString: () => reflex.error
})
)
}
Expand Down
12 changes: 8 additions & 4 deletions javascript/controllers.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import Schema from './schema'
import Stimulus from './app'

import { attributeValues } from './attributes'
import { extractReflexName } from './utils'

// Returns StimulusReflex controllers local to the passed element based on the data-controller attribute.
//
const localReflexControllers = (app, element) => {
const localReflexControllers = element => {
return attributeValues(element.getAttribute(Schema.controller)).reduce(
(memo, name) => {
const controller = app.getControllerForElementAndIdentifier(element, name)
const controller = Stimulus.app.getControllerForElementAndIdentifier(
element,
name
)
if (controller && controller.StimulusReflex) memo.push(controller)
return memo
},
Expand All @@ -19,10 +23,10 @@ const localReflexControllers = (app, element) => {
// Returns all StimulusReflex controllers for the passed element.
// Traverses DOM ancestors starting with element.
//
const allReflexControllers = (app, element) => {
const allReflexControllers = element => {
let controllers = []
while (element) {
controllers = controllers.concat(localReflexControllers(app, element))
controllers = controllers.concat(localReflexControllers(element))
element = element.parentElement
}
return controllers
Expand Down
12 changes: 12 additions & 0 deletions javascript/isolation_mode.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Deprecate from './deprecate'

let isolationMode = false

export default {
Expand All @@ -6,5 +8,15 @@ export default {
},
set (value) {
isolationMode = value
if (Deprecate.enabled && !isolationMode) {
document.addEventListener(
'DOMContentLoaded',
() =>
console.warn(
'Deprecation warning: the next version of StimulusReflex will standardize isolation mode, and the isolate option will be removed.\nPlease update your applications to assume that every tab will be isolated. Use CableReady operations to broadcast updates to other tabs and users.'
),
{ once: true }
)
}
}
}

0 comments on commit f166233

Please sign in to comment.