Skip to content

Commit

Permalink
Ensure we always render errors with the Append strategy (#134)
Browse files Browse the repository at this point in the history
## New Features

* Better error handling to help developers
* Better abort handling to help developers
* Switch to Rack middleware ingress/egress for commands
* Revamp state management
* Add page state tracking and restoration

## Improvements and Fixes

* Ensure finish event is dispatched on abort/error
* Add more tests
* Get optimistic state restoration working reliably
* Switch to localStorage so state persists across tabs
  • Loading branch information
hopsoft committed May 21, 2024
1 parent 9b4535c commit d27d846
Show file tree
Hide file tree
Showing 68 changed files with 1,635 additions and 682 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
.byebug_history
.containers.yml
.pnp.*
.playwright*
.yarn*
/.bundle/
/doc/
Expand All @@ -26,4 +27,4 @@ test/dummy/log/*.log*
/.appmap

# Vendored Ruby gems
/vendor
/vendor
2 changes: 1 addition & 1 deletion .standard.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
ruby_version: 2.7
ruby_version: 3.0
format: progress
parallel: true

Expand Down
89 changes: 62 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</h1>
<p align="center">
<a href="http://blog.codinghorror.com/the-best-code-is-no-code-at-all/">
<img alt="Lines of Code" src="https://img.shields.io/badge/loc-1450-47d299.svg" />
<img alt="Lines of Code" src="https://img.shields.io/badge/loc-1689-47d299.svg" />
</a>
<a href="https://codeclimate.com/github/hopsoft/turbo_boost-commands/maintainability">
<img src="https://api.codeclimate.com/v1/badges/fe1162a742fe83a4fdfd/maintainability" />
Expand Down Expand Up @@ -59,29 +59,30 @@

## Table of Contents

- [Why TurboBoost Commands?](#why-turboboost-commands)
- [Sponsors](#sponsors)
- [Dependencies](#dependencies)
- [Setup](#setup)
- [Usage](#usage)
- [Event Delegates](#event-delegates)
- [Lifecycle Events](#lifecycle-events)
- [Targeting Frames](#targeting-frames)
- [Working with Forms](#working-with-forms)
- [Server Side Commands](#server-side-commands)
- [Appending Turbo Streams](#appending-turbo-streams)
- [Setting Instance Variables](#setting-instance-variables)
- [Prevent Controller Action](#prevent-controller-action)
- [Broadcasting Turbo Streams](#broadcasting-turbo-streams)
- [Community](#community)
- [Developing](#developing)
- [Notable Files](#notable-files)
- [Deploying](#deploying)
- [Notable Files](#notable-files-1)
- [How to Deploy](#how-to-deploy)
- [Releasing](#releasing)
- [About TurboBoost](#about-turboboost)
- [License](#license)
- [Why TurboBoost Commands?](#why-turboboost-commands)
- [Sponsors](#sponsors)
- [Dependencies](#dependencies)
- [Setup](#setup)
- [Usage](#usage)
- [Event Delegates](#event-delegates)
- [Lifecycle Events](#lifecycle-events)
- [Targeting Frames](#targeting-frames)
- [Working with Forms](#working-with-forms)
- [Server Side Commands](#server-side-commands)
- [Appending Turbo Streams](#appending-turbo-streams)
- [Setting Instance Variables](#setting-instance-variables)
- [Prevent Controller Action](#prevent-controller-action)
- [Broadcasting Turbo Streams](#broadcasting-turbo-streams)
- [Tracking Page State](#tracking-page-state)
- [Community](#community)
- [Developing](#developing)
- [Notable Files](#notable-files)
- [Deploying](#deploying)
- [Notable Files](#notable-files-1)
- [How to Deploy](#how-to-deploy)
- [Releasing](#releasing)
- [About TurboBoost](#about-turboboost)
- [License](#license)

<!-- Tocer[finish]: Auto-generated, don't remove. -->

Expand Down Expand Up @@ -381,7 +382,8 @@ end

_This proves especially powerful when paired with [TurboBoost Streams](https://github.com/hopsoft/turbo_boost-streams)._

> 📘 **NOTE:** `turbo_stream.invoke` is a [TurboBoost Streams](https://github.com/hopsoft/turbo_boost-streams#usage) feature.
> [!NOTE]
> `turbo_stream.invoke` is a [TurboBoost Streams](https://github.com/hopsoft/turbo_boost-streams#usage) feature.
### Setting Instance Variables

Expand Down Expand Up @@ -476,7 +478,40 @@ end
_Learn more about Turbo Stream broadcasting by reading through the
[hotwired/turbo-rails](https://github.com/hotwired/turbo-rails/blob/main/app/models/concerns/turbo/broadcastable.rb) source code._

> 📘 **NOTE:** `broadcast_invoke_later_to` is a [TurboBoost Streams](https://github.com/hopsoft/turbo_boost-streams#broadcasting) feature.
> [!NOTE]
> `broadcast_invoke_later_to` is a [TurboBoost Streams](https://github.com/hopsoft/turbo_boost-streams#broadcasting) feature.
### Tracking Page State

You can opt-in to remember transient page state when using Rails tag helpers with `turbo_boost[:remember]` to track
element attribute values between requests.

```erb
<%= tag.details id: "page-state-example", open: "open", turbo_boost: { remember: [:open] } do %>
<summary>Page State Example</summary>
Content...
<% end %>
```

The code above will be expanded to this HTML.

```html
<details id="page-state-example" open="open" data-turbo-boost-state-attributes="['open']">
<summary>Page State Example</summary>
Content...
</details>
```

Several things happen when you use `turbo_boost[:remember]` to track page state.

1. The client builds the current page state before emitting requests to the server.
1. The server uses the page state when rendering the response.
1. The client client verifies the page state and restores attribute values _(if necessary)_ after the DOM updates.

This feature works with all attributes, including aria, data, and custom attributes.

> [!NOTE]
> Elements must have a unique `id` assigned to participate in page state tracking.
## Community

Expand Down Expand Up @@ -558,7 +593,7 @@ fly deploy
1. Commit and push any changes to GitHub
1. Run `rake release`
1. Run `npm publish --access public`
1. Create a new release on GitHub ([here](https://github.com/hopsoft/turbo_boost-commands/releases)) and generate the changelog for the stable release for it
1. Create a new release on GitHub ([here](https://github.com/hopsoft/turbo_boost-commands/releases))
## About TurboBoost
Expand Down
2 changes: 1 addition & 1 deletion app/assets/builds/@turbo-boost/commands.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions app/assets/builds/@turbo-boost/commands.js.map

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ module TurboBoost::Commands::Controller

included do
before_action -> { turbo_boost.runner.run }
after_action -> { turbo_boost.runner.update_response }
after_action -> { turbo_boost.runner.flush }
helper_method :turbo_boost
end

Expand Down
1 change: 0 additions & 1 deletion app/javascript/elements.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import schema from './schema'
import lifecycle from './lifecycle'

function findClosestCommand(element) {
return element.closest(`[${schema.commandAttribute}]`)
Expand Down
9 changes: 6 additions & 3 deletions app/javascript/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ export const commandEvents = {
}

export const stateEvents = {
stateLoad: 'turbo-boost:state:load',
stateChange: 'turbo-boost:state:change'
stateChange: 'turbo-boost:state:change',
stateInitialize: 'turbo-boost:state:initialize'
}

export const allEvents = { ...commandEvents, ...stateEvents }
export const turboEvents = {
frameLoad: 'turbo:frame-load',
load: 'turbo:load'
}

export function dispatch(name, target, options = {}) {
return new Promise(resolve => {
Expand Down
4 changes: 2 additions & 2 deletions app/javascript/headers.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ const prepare = (headers = {}) => {
// Tokenizes the 'TurboBoost-Command' HTTP response header value
const tokenize = value => {
if (value) {
const [status, strategy, name] = value.split(', ')
return { status, strategy, name }
const [name, status, strategy] = value.split(', ')
return { name, status, strategy }
}

return {}
Expand Down
30 changes: 19 additions & 11 deletions app/javascript/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import confirmation from './confirmation'
import delegates from './delegates'
import drivers from './drivers'
import elements from './elements'
import lifecycle from './lifecycle'
import './lifecycle'
import logger from './logger'
import state from './state'
import uuids from './uuids'
Expand All @@ -29,14 +29,16 @@ const Commands = {

function buildCommandPayload(id, element) {
return {
id, // uniquely identifies the command
name: element.getAttribute(schema.commandAttribute),
elementId: element.id.length > 0 ? element.id : null,
elementAttributes: elements.buildAttributePayload(element),
startedAt: Date.now(),
changedState: state.changed, // changed-state (delta of optimistic updates)
clientState: state.current, // client-side state
signedState: state.signed // server-side state
id, //---------------------------------------------------------- Uniquely identifies the command invocation
name: element.getAttribute(schema.commandAttribute), //--------- Command name
elementId: element.id.length > 0 ? element.id : null, //-------- ID of the element that triggered the command
elementAttributes: elements.buildAttributePayload(element), //-- Attributes of the element that triggered the command
startedAt: Date.now(), //--------------------------------------- Start time of when the command was invoked
state: {
page: state.buildPageState(),
signed: state.signed,
unsigned: state.unsigned
}
}
}

Expand All @@ -49,7 +51,7 @@ async function invokeCommand(event) {
if (!element) return
if (!delegates.isRegisteredForElement(event.type, element)) return

const commandId = `turbo-command-${uuids.v4()}`
const commandId = uuids.v4()
let driver = drivers.find(element)
let payload = {
...buildCommandPayload(commandId, element),
Expand Down Expand Up @@ -108,14 +110,20 @@ if (!self.TurboBoost.Commands) {
delegates.handler = invokeCommand
delegates.register('click', [`[${schema.commandAttribute}]`])
delegates.register('submit', [`form[${schema.commandAttribute}]`])
delegates.register('toggle', [`details[${schema.commandAttribute}]`])
delegates.register('change', [
`input[${schema.commandAttribute}]`,
`select[${schema.commandAttribute}]`,
`textarea[${schema.commandAttribute}]`
])

self.TurboBoost.Commands = Commands
self.TurboBoost.State = state
self.TurboBoost.State = {
initialize: state.initialize,
get current() {
return state.unsigned
}
}
}

export default Commands
12 changes: 2 additions & 10 deletions app/javascript/invoker.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,16 @@
import headers from './headers'
import lifecycle from './lifecycle'
import state from './state'
import urls from './urls'
import { dispatch } from './events'
import { render } from './renderer'

const parseError = error => {
const errorMessage = `Unexpected error performing a TurboBoost Command! ${error.message}`
dispatch(lifecycle.events.clientError, document, { detail: { error: errorMessage } }, true)
const message = `Unexpected error performing a TurboBoost Command! ${error.message}`
dispatch(lifecycle.events.clientError, document, { detail: { message, error } }, true)
}

const parseAndRenderResponse = response => {
const { strategy } = headers.tokenize(response.headers.get(headers.RESPONSE_HEADER))

// FAIL: Status outside the range of 200-399
if (response.status < 200 || response.status > 399) {
const error = `Server returned a ${response.status} status code! TurboBoost Commands require 2XX-3XX status codes.`
dispatch(lifecycle.events.serverError, document, { detail: { error, response } }, true)
}

response.text().then(content => render(strategy, content))
}

Expand Down
9 changes: 3 additions & 6 deletions app/javascript/lifecycle.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@ import activity from './activity'
import { dispatch, commandEvents } from './events'

function finish(event) {
event.detail.endedAt = Date.now()
event.detail.milliseconds = event.detail.endedAt - event.detail.startedAt
setTimeout(() => dispatch(commandEvents.finish, event.target, { detail: event.detail }), 25)
setTimeout(() => dispatch(commandEvents.finish, event.target, { detail: event.detail }))
}

// TODO: forward source event to finish (error or success)
addEventListener(commandEvents.serverError, finish)
addEventListener(commandEvents.success, finish)
const events = [commandEvents.abort, commandEvents.serverError, commandEvents.success]
events.forEach(name => addEventListener(name, finish))
addEventListener(commandEvents.finish, event => activity.remove(event.detail.id), true)

export default { events: commandEvents }
31 changes: 29 additions & 2 deletions app/javascript/logger.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { allEvents as events } from './events'
// TODO: Move Logger to its own library (i.e. TurboBoost.Logger)
import { commandEvents as events } from './events'

let currentLevel = 'unknown'
let initialized = false
Expand Down Expand Up @@ -28,10 +29,36 @@ const shouldLogEvent = event => {
return true
}

const logMethod = event => {
if (logLevels.error.includes(event.type)) return 'error'
if (logLevels.warn.includes(event.type)) return 'warn'
if (logLevels.info.includes(event.type)) return 'info'
if (logLevels.debug.includes(event.type)) return 'debug'
return 'log'
}

const logEvent = event => {
if (shouldLogEvent(event)) {
const { target, type, detail } = event
console[currentLevel](type, detail.id || '', { target, detail })
const id = detail.id || ''
const commandName = detail.name || ''

let duration = ''
if (detail.startedAt) duration = `${Date.now() - detail.startedAt}ms `

const typeParts = type.split(':')
const lastPart = typeParts.pop()
const eventName = `%c${typeParts.join(':')}:%c${lastPart}`
const message = [`%c${commandName}`, `%c${duration}`, eventName]

console[logMethod(event)](
message.join(' ').replace(/\s{2,}/g, ' '),
'color:deepskyblue',
'color:lime',
'color:darkgray',
eventName.match(/abort|error/i) ? 'color:red' : 'color:deepskyblue',
{ id, detail, target }
)
}
}

Expand Down
16 changes: 11 additions & 5 deletions app/javascript/renderer.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import uuids from './uuids'

const append = content => {
document.body.insertAdjacentHTML('beforeend', content)
}

const replace = content => {
const parser = new DOMParser()
const doc = parser.parseFromString(content, 'text/html')
TurboBoost.Streams.morph.method(document.documentElement, doc.documentElement)
const head = document.querySelector('head')
const body = document.querySelector('body')
const newHead = doc.querySelector('head')
const newBody = doc.querySelector('body')
if (head && newHead) TurboBoost?.Streams?.morph?.method(head, newHead)
if (body && newBody) TurboBoost?.Streams?.morph?.method(body, newBody)
}

// TODO: dispatch events after append/replace so we can apply page state
export const render = (strategy, content) => {
if (strategy.match(/^Append$/i)) return append(content)
if (strategy.match(/^Replace$/i)) return replace(content)
if (strategy && content) {
if (strategy.match(/^Append$/i)) return append(content)
if (strategy.match(/^Replace$/i)) return replace(content)
}
}

export default { render }
3 changes: 2 additions & 1 deletion app/javascript/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ const schema = {
frameAttribute: 'data-turbo-frame',
methodAttribute: 'data-turbo-method',
commandAttribute: 'data-turbo-command',
confirmAttribute: 'data-turbo-confirm'
confirmAttribute: 'data-turbo-confirm',
stateAttributesAttribute: 'data-turbo-boost-state-attributes'
}

export default { ...schema }
Loading

0 comments on commit d27d846

Please sign in to comment.