Photo Shuffle is still broken in the iOS 18 Lock Screen
For iOS 16, Apple overhauled the iPhone lock screen and the one feature they shipped that I really, really wanted was the ability to shuffle depth-effect photos of my spouse. It's called "Photo Shuffle", and you get there by adding a new lock screen, tapping "Photo Shuffle", and selecting "People". The Big Idea is that your phone would use machine learning to select great photos and then apply a depth effect (i.e. clipping the subject in front of the time). However, instead of having users select "People & Pets" from a standard iCloud Photos picker, you get an arbitrary smattering of a couple dozen randos in a bare bones custom UI.
So what's my beef with this feature? Over the course of 2 years and 7 devices, my wife has never been among the options presented to me. Can't select her. Doesn't matter that I've named her in the Photos app. Or favorited her. My library has over 25,000 photos of her for crissakes. Who can I pick from instead? Well, there are least 3 kids whose names I never knew and for whom Becky appears to have had as Spanish students for a single semester in 2009. Great job, everyone.
As it turns out, I am not alone.
I first encountered this bug in iOS 16 developer beta 1 on June 6, 2022. It has persisted across four iPhones and three iPads, even when set up fresh, not-from-backup. Not only that, I always see the exact same list of people I don't care about. Most of whom I never even bothered to name in Photos, which suggests the bug lives in the cloud, which is just great.
Jason Snell reported on this feature's problematic design last year (during iOS 17 beta season), for MacWorld:
Photo Shuffle's method of offering people to display appears utterly broken. It offered my wife a small number of faces, most of whom were completely random and fairly uncommon. She's got hundreds, if not thousands, of pictures of me and our kids on her phone, and yet we weren't among the faces offered. And if the faces you're looking for aren't in Photo Shuffle's very small list of options, there's no recourse. You're stuck.
Well, here we are, one year later, and I'm unhappy to report: Photo Shuffle is still broken in iOS 18. It doesn't seem to have been touched at all.
When people talk about the inscrutability of machine-learning and AI as being problematic, this is as practical an example as I can think of. All I want to do is shuffle photos of my wife on my lock screen, but there's no action I can take as a user—no amount of hardware purchases, software updates, or device factory resets—to make that happen. Apple Support can't do anything either. I doubt the engineers who worked on it could. Whenever anyone says "AI", everyone involved quickly absolves themselves of responsibility—it's a black box.
I wasn't happy with the Faker gem's Lorem and Markdown modules, so—for anyone who might need it—I wrote a silly function to generate more realistically sized paragraphs with Markdown bits gist.github.com/searls/2859ad7e8941872edb9561eb965b7c76
Big day: now with transition music! 🎶
There's one topic that's been dominating headlines for the last two weeks, but if you want to hear about it you'll have to look elsewhere! That's because Breaking Change is a safe space. So listen up and let's find other things to get mad about.
Do you have opinions about politics? Do you want to share them with an Internet friend? Now's your chance: podcast@searls.co. (Other topics also welcome.)
I heard you like links:
- A "recipe" blog post on how to use Turbo Streams to swap out content
- This month's newsletter about work pressure
- My Vision Pros interview
- Today's pun, ranked
- Tim Cook uses Vision Pro every day
- All the ways Japan is trying to stop you from visiting (News+)
- White-collar Work is Just Meetings Now (News+)
- Star Trek Discovery
- Steve! (Martin) documentary
- Remembering Gene Wilder
- Airfoil Satellite for Windows Airplay
- Star Wars: Jedi Survivor
- Sunny (Apple TV)
- Objectified film
I really enjoyed this discussion with host Tim Chaten about the state of Apple Vision Pro. It was recorded a couple weeks after WWDC, which meant the memory was fresh enough to keep all of Apple's announcements top of mind but distant enough to imagine various directions things could go from here.
I gotta say, it was nice talking to someone who knows and cares more about the platform than I do. Some real "there are dozens of us!" energy around the Vision Pro right now.
Recipe: Swapping out a model div with Turbo Streams and Stimulus
Rails + Hotwire is very capable of dynamic behavior like replacing a component in the DOM by sending HTML over-the-wire in response to a user action, but the fact it requires you to touch half a dozen files in the process can make the whole thing feel daunting. Rails itself has always been this way (with each incremental feature requiring a route, model, controller, view, etc.), but I've been using it long enough that I sometimes forget that—similar to learning a recipe—I originally needed months of intentional practice to internalize and gain comfort with the framework's most routine of workflows.
So, like a recipe card, here is a reusable approach to swapping out a <div>
rendered by a Rails partial with a turbo stream whenever a user selects an
alternate model from an input (specifically, a select) using Turbo 8, Stimulus
1.3, and Rails 7.1.
The ingredients
- Partial: Extract a partial to be rendered inside the element you wish to replace, so that both your view and your turbo stream can render the same markup for a given model
- Routing: Add a route specifying a one-off controller action that will respond with a turbo stream
- Controller Action: Define an action that takes your model ID and the DOM element's ID and responds with a turbo stream to update the element's contents
- Turbo Stream View: Create a turbo stream view for the action that invokes the partial
- Stimulus Controller: Create a generic Stimulus controller that can swap any model type when given a path, ID, and container
- View: Wire up the Stimulus controller to the view's select box and the to-be-replaced element
That's it, 6 key ingredients. If you're curious, step 5 contains the most magic flavoring. 🪄
The actions
Ingredients in hand, let's walk through each of the steps needed to go end-to-end with this feature.
1. Set Up the Rails Partial
First, create a partial that you want to render inside the <div>. Let's assume
we want users to be able to change out a generic model named Item, which has
a conventional ItemsController.
In that case, let's place a partial that renders the details about an item alongside
the controller's views, in _detail.html.erb:
<!-- app/views/items/_detail.html.erb -->
<div>
<%= item.title %>
<!-- Other item stuff… -->
</div>
2. Add Routes
Next, we'll add the necessary route for the detail action:
# config/routes.rb
resources :items do
get :detail, on: :collection
end
This will define a path helper detail_items_path, which works out to
"/items/detail".
Note that I threw this on the :collection so that our stimulus controller can
more easily specify the URL via query parameters instead of interpolating a
fancier member route (e.g. "items/42/detail").
3. Define the Controller Action
With the route defined, we'll add a simple controller action that only responds to turbo stream requests.
Here's what that might look like:
# app/controllers/items_controller.rb
class ItemsController < ApplicationController
def detail
@dom_id = params[:dom_id]
@item = Item.find(params[:id])
end
end
This dom_id param might throw you off at first, but it's important to keep in
mind that unique HTML IDs are the coin of the realm in
Turbo-land. You'll see how it
gets set later, in step 5.
4. Create the Turbo Stream View
To finish the route-controller-view errand, we'll create a view for the detail action,
with the turbo_stream.erb extension instead of html.erb:
<!-- app/views/items/detail.turbo_stream.erb -->
<%= turbo_stream.update @dom_id do %>
<%= render partial: "detail", locals: { item: @item } %>
<% end %>
Because both the turbo stream and the original view need to render items in
exactly the same way, detail.turbo_stream.erb view responds by rendering the
_detail.html.erb partial. If you inspect the HTML that comes over the wire,
you'll see that only the turbo stream tag containing this partial is
transferred, which often means barely more data is transferred than had we
implemented this as a single-page JavaScript by making a similar HTTP request
for JSON.
5. Define the Stimulus Controller
In order for users' selections to have any effect, we need JavaScript. We could
write a Stimulus controller that's coupled specifically to this Item model,
but it's no more work to make it generic, which would allow us to reuse this
functionality elsewhere in our app. So let's do that.
You can do this the hard way by using the browser's built-in fetch API to
construct the URL, set the Accept header to text/vnd.turbo-stream.html, and
replace the element's innerHTML in the DOM, but that's easy to screw up (in
fact, I screwed it up twice while writing this). So instead, I'd recommend pulling
in the requestjs-rails gem, by first
chucking it in your Gemfile alongside any other front-end related gems:
gem "requestjs-rails"
Here's the final Stimulus controller. Deep breath, as I haven't explained all this yet:
// app/javascript/controllers/model_swap_controller.js
import { get } from '@rails/request.js'
import { Controller } from '@hotwired/stimulus'
export default class ModelSwapController extends Controller {
static targets = ['container']
static values = {
path: String,
originalId: String
}
swap (event) {
const modelId = event.currentTarget.value || // Value from input action
event.detail?.value || // Value from custom event (e.g. hotwire_combobox)
this.originalIdValue // Fallback to original value if input value is cleared
get(this.pathValue, {
query: {
id: modelId,
dom_id: this.containerTarget.id
},
responseKind: 'turbo-stream'
})
}
}
That get function from @rails/request.js handles all the housekeeping you
might hope it would. When I switched to it, the fact it worked the
instant I plopped it in gave me Dem Magic Vibes that keep me coming back to
Rails 18 years in.
This controller also contains two values and a target:
pathvalue: this is just a URL, which we'll set to our intentionally-parameter-freedetail_items_pathoriginalIdvalue: this is theItemID that was first rendered when the page loaded. By having this available as a fallback, we'll be able to gracefully handle the user choosing a blank option from theselectby restoring the original itemcontainertarget: this is the DOM element containing the partial we're going to swap out. Note that it must have a uniqueidattribute, which we're including in our request to the server asdom_id
If this doesn't make perfect sense, I recommend wiring it up anyway and getting it working first, then debugging to inspect the values in motion.
6. Wiring up the Stimulus Controller in the View
Finally, we'll visit the original view from which the _detail.html.erb partial
was initially extracted.
Right off the bat, you might notice that I like to use content_tag whenever I
need to specify numerous attributes with Ruby expressions, as it requires
far fewer <%=%> interpolations than specifying a literal <div>:
<!-- app/views/items/show.html.erb -->
<%= content_tag :div, data: {
controller: "model-swap",
model_swap_path_value: detail_items_path,
model_swap_original_id_value: @item.id,
} do %>
<%= collection_select :item, :id, Item.all, :id, :title,
{include_blank: true},
{data: {action: "model-swap#swap"}} %>
<div id="<%= dom_id(@item, "detail") %>" data-model-swap-target="container">
<%= render partial: "detail", locals: { item: @item } %>
</div>
<% end %>
The above will probably look immediately familiar to anyone who's done a lot of work with Stimulus before and utterly arcane otherwise. Helping you sort out the latter is beyond the scope of this article, though. Ask ChatGPT or something.
The only thing in the above template that wasn't completely preordained by the
first 5 steps was the id attribute of the wrapping div element, so I'll
explain that here. For illustration purposes, I set the container div to
dom_id(@item, "detail") (which would work out to something like
"detail_item_42") to give an example of something that's likely to be unique,
but in truth, the most appropriate ID will depend on what's going on in the
broader page. For example, in the UI that inspired this blog post, I am allowing
users to replace any of a variable array of items across 3 options, so my IDs
are based on those indices, like option_2_item_4, as opposed to the database
ID of any models. All that really matters is that the ID be unique.
That's it!
Pulling off functionality like this with Turbo and Stimulus feels extra delightful, I think, if you (like so many of us) spent the last decade assuming that this kind of snappy, dynamic behavior would require a front-end JavaScript framework that would live forever and keep track of a duplicated copy of the app's state. Instead, because the server-side rendered view can draw the entire page without any JavaScript involved, any client-side changes we introduce to the state of the DOM can operate on attributes first defined by the view, keeping the entire source of truth of the current application state in one place (the DOM) instead of two (a server database and in-memory JavaScript objects).
Anyway, when it works, it's great. And when the lego bricks aren't snapping together for whatever reason, it's infuriating. Which maybe makes Hotwire the most Rails-assed extension to the framework since Rails itself. If you find yourself losing a bunch of time to what seem like trivial naming issues, just know that you're not alone. This stuff takes practice to get used to.
If you've worked through this guide, hopefully you have a functioning feature that you can continue iterating on. If you stumbled over any errata above, please let me know.
Was featured on Timothy Chaten's "Vision Pros" podcast, a show where the dozens of us dedicated Vision Pro users gather to chat about the fledgling platform.
Appearing on: Vision Pros
Published on: 2024-07-11
Original URL: https://visionpros.fm/2024/07/11/episode-19-justin-searls-from-breaking-change/
Comments? Questions? Suggestion of a podcast I should guest on? podcast@searls.co
Make Command-Return submit your web form
Hello, I just wanted to say that if you want your web app to feel Cool and Modern, one of the easiest things you can do is make it so that Mac users can hit command-return anywhere in the form to submit it.
Some web sites map this to control-enter for other platforms, and that's fine, but I don't bother. Truth be told, I used to bother, but after adding it to a few web apps years ago, I actually had multiple Windows and Linux users complain to me about unintended form submissions.
I am not making a comment on the sophistication of non-Apple users, but I am saying that if you just stick this code at the top of your app, it will make it more Exclusive and feel Snappier and I will thank you for it.
Here, just copy and paste this. Don't even bother reading it first:
document.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && event.metaKey) {
if (!document.activeElement) return
const closestForm = document.activeElement.closest('form')
if (closestForm) {
event.preventDefault()
closestForm.requestSubmit()
}
}
})
There's been a bug in the Apple Watch app ever since multiple watch support was added: if the pairing process fails on an additional watch and the phone begins unpairing it, ALL paired watches will be unpaired.
There is no way to recover short of setting all watches up all over again. Today marks the sixth time this has happened to me.
Why I just uninstalled my own VS Code extension
After a little over a year of prodding by Vini Stock to
ship a Standard Ruby add-on for Ruby
LSP, and thanks to a lot of help from
Shopify's Ruby DX team, I've
finally done it! In fact, so long as your Gemfile's version of standard is
at least 1.39.1, you already have the new Ruby LSP add-on. It's built-in!
Ruby LSP supports any editor with language server support, but configuration varies from editor to editor. Since VS Code is relatively dominant, I added some docs on how to set it up, but most Ruby LSP users will just need these settings to select Standard as their linter and formatter:
"[ruby]": {
"editor.defaultFormatter": "Shopify.ruby-lsp"
},
"rubyLsp.formatter": "standard",
"rubyLsp.linters": [
"standard"
]
I've been using this configuration for a bit over a week and I've decided: it's time to uninstall my own bespoke extension that we launched early last year .
I've also updated Standard's README to explain why the new Ruby LSP add-on is superior to our own built-in language server. In short, the Ruby LSP add-on supports pull diagnostics and code actions, and the built-in server does not.
Standard Ruby's built-in language server and existing VS Code extension will continue to work and be supported for the forseeable future, but it doesn't make much sense to invest heavily into new features, when the Ruby LSP add-on will get them "for free".
Why make the switch?
Three reasons:
- Capability. Ruby LSP centralizes the pain of figuring out how to build a full-featured, performant language server. The issue isn't that implementing a basic STDIO server is All That Hard, it's that rolling your own utilities like logging, debugging, and test harnesses are a huge pain in the ass. By plugging into Ruby LSP as an add-on, library authors can integrate with simpler high-level APIs, exploit whatever LSP capabilities it implements and whatever utilities it exposes, and spare themselves from re-inventing Actually Hard things like project-scoped code indexing (instead, leveraging Ruby LSP's robust, well-tested index)
- Duplication. RuboCop maintainer Koichi Ito gave the closest thing to a barn-burner presentation about language servers at RubyKaigi that I could imagine, where he discussed the paradoxical wastefulness of every library author hand-rolling the same basic implementation while simultaneously needing their own tightly-integrated language server to push their tools' capabilities forward. In the case of Standard Ruby, we're squeezed on both sides: at one end, a Ruby LSP add-on would be a more convenient, batteries-included solution than publishing our own extension; at the other, nuking our own custom LSP code and delegating to RuboCop's built-in language server would unlock capabilities we couldn't hope to provide ourselves
- Maintainability. You think I enjoy maintaining any of this shit?
Embracing defeat
So yeah, in the medium-term future, I see Ruby LSP and RuboCop as being better-positioned to offer a language server than Standard itself. Thanks to Will Leinweber's implementation, we may have been there first, but I have nothing to gain by my spending free time to ensure our server is somehow better than everyone else's. In the long-term, even more consolidation seems likely—which probably means Ruby LSP will become dominant. But ultimately, they're called language servers for a reason, and if Ruby shipped with a built-in language server (and an API that any code could easily plug into), it could prove a competitive advantage over other languages while simultaneously enabling a new class of tools that could each pitch in to enhance the developer experience in distinct, incremental ways.
On a human level, I think it's important not to associate the prospect of retiring one's own work with feelings of failure. Code is a liability, not an asset. Whenever I can get by with less of it, I feel relief after discarding it. If relief isn't your default reaction to a competing approach winning out on the merits (and it's understandable if it isn't; pride of authorship is a thing), I encourage you to figure out how to adopt this mindset. There are far too many problems out there worth solving to waste a single minute defending the wrong solution.
Anyway, go try out Standard with Ruby LSP and tell me how it goes! I'll be bummed if I didn't manage to break at least something.
Artificially-induced intrinsic motivation
This is a copy of the Searls of Wisdom newsletter delivered to subscribers on July 6, 2024.
June was a busy month. Because Becky's business is in a sort of limbo until I finish and because I'm giving a talk at Rails World about how easy it was to finish, I am extremely heads-down finishing my work on her Better with Becky app. I can see the light at the end of the tunnel, though. I think.
This moment in every project reminds me of one of my favorite tongue-in-cheek "laws" of computing:
The first 90 percent of the code accounts for the first 90 percent of the development time. The remaining 10 percent of the code accounts for the other 90 percent of the development time.
One thing I will say is, while the ninety-ninety rule has rung true to me for nearly every team project I've been a part of in a corporate context, it doesn't resonate at all when it comes to my solo work. Maybe it's the acceleration one can build when the full context of the code resides entirely in one's own head. Maybe it's my particular "measure twice, cut once" ethos as applied to planning and testing. More likely, it's that when the person planning the work is the same person that's doing the work, scope creep becomes emotionally untenable once they reach the point of exhaustion. Reminds me of that quote often misattributed to Leonardo Da Vinci, "a work of art is never finished, merely abandoned."
Anyway, because I've been so busy building that app, I haven't gotten out much. Of course, I say that in Orlando-adjusted terms, as I did manage to find time to meet a beloved character actor from Raiders of the Lost Ark, attend a preview event to ride a new theme park attraction, and see the new neighborhood drone show in between pomodoro timers.

Today, we're going to talk about pressure.
Pro-tip: make your debug print statements POP 🍾
This isn't an exciting idea, but since I know a lot of puts debuggerers who are probably accustomed to printing plain text into a sea of log noise, I thought I'd share this PSA: making your print statements visually distinctive makes them easier to spot.
If you're trying to log a value during the request/response lifecycle of a
Rails development server, there's a lot of chatter for
every page load. So instead of passing something to puts or pp and spraining
a squint muscle to find your print statement in the server logs, do something to
make it stand out!
You could reach for a gem like awesome_print for this, but IMNSHO there's no need to add another dependency just for this.
Since I use a white-background terminal, my quick solution was to define a
method that pretty-prints its argument with a black background (called
Kernel#buts). Since I only need this for my development server, I chucked it
in a development-only initializer:
# config/initializers/buts.rb
return unless Rails.env.development?
module Kernel
# Make your output stand out more in the Rails server console
def buts(obj)
black_background_white_text = "\e[30;47m"
reset = "\e[0m"
puts "#{black_background_white_text}#{obj.pretty_inspect}#{reset}"
end
end
Here's the before-and-after. At a glance, can you even see what I'm trying to print in the first screenshot?
There you go. Life's too short to be hunting through logs for one-off print statements. 🔎
Over and over and over again, the same lesson: the most valuable thing a programmer can do for themselves is to invest in faster, safer feedback loops.
Forced myself to spend two hours this morning not building the thing, and the resulting script empowered me to make such aggressive and rapid changes that I accomplished a day's worth of work in the subsequent two hours.
Instantiate a custom Rails FormBuilder without using form_with
I'm building a Rails app using Tailwind, which works really well for almost everything out-of-the-box except forms, because—unless you relish the idea of repeating the exact same list of CSS classes for each and every field in your app—you're going to be left wanting for some way to extract all that messy duplication.
To that end, I've gradually built up a custom FormBuilder to house all my classes. (If you're looking for a starting point, here's a gist of what my TailwindFormBuilder currently looks like).
This works great when you're using form_with, because the custom form builder
will automatically take over when you set the builder option:
<%= form_with model: @user, builder: TailwindFormBuilder do |form| %>
<% end >
And if you set it globally with ActionView::Base.default_form_builder = FormBuilders::TailwindFormBuilder, the custom builder becomes the default.
Nifty!
But what about when you need to render input elements outside the context of a
proper form? Today, I wanted to render some checkboxes for a client-side UI that
would never be "submitted" and for which no object made sense as an argument to
form_with. Both immediately-available options are bad:
- Wrapping those checkboxes in an unnecessary
<form>tag by passing a dummy object toform_with, just for the side effect of having myTailwindFormBuilderinvoked, seemed kind of silly - Using one of Rails' built-in form helpers that work outside
form_with, like check_box_field, wouldn't invoke myTailwindFormBuilderand would therefore lack any of its CSS classes
Instead, I figured the best path forward would be to instantiate my form builder myself, even though that's not something the docs would encourage you to do to. So, I pulled up the FormBuilder#initialize source to see what arguments it needed:
def initialize(object_name, object, template, options)
# …
end
Lucky for us, the only thing that really matters above is the template object,
which I (correctly, apparently) guessed could be passed as self from an ERB
file or a view helper.
Here's a little helper I made to instantiate my custom TailwindFormBuilder manually:
# app/helpers/faux_form_helper.rb
module FauxFormHelper
FauxFormObject = Struct.new do
def errors
{}
end
def method_missing(...)
end
def respond_to_missing?(...)
true
end
end
def faux_form
@faux_form ||= FormBuilders::TailwindFormBuilder.new(
nil,
FauxFormObject.new,
self,
{}
)
end
end
Explaining each of these arguments in order:
object_nameis set to nil, so thenameattribute of each input isn't wrapped in brackets (e.g.name="some_object_name[pants]")objectdoesn't matter, because this isn't a real form, so myFauxFormObjectjust responds as if every possible value is a real property, as well as toerrorswith an empty hash (which my form builder uses to determine when to highlight validation problems in red)templateis set toself, because that seems to workoptionsis left as an empty hash, because I don't appear to depend on any yet
This, in turn, lets me render consistently-styled form fields anywhere I like. As a result, this ERB:
<%= faux_form.check_box(:pants, checked: true) %>
Will render with all my default checkbox classes:
<input
type="checkbox" value="1" checked="checked" name="pants" id="pants"
class="block rounded-sm size-3.5 focus:ring-3 focus:ring-success checked:bg-success checked:hover:bg-success/90 cursor-pointer focus:ring-opacity-50 border border-gray-300 focus:border-success"
>
This incongruity has been a pebble in my shoe for a couple years now, so I'm glad to finally have a working solution for rendering Tailwind-ified fields both inside and outside the context of a proper HTML form.
Hope you find it helpful! 🦦🪄
Just got this email about my inadvisably authentic podcast, Breaking Change:
"Feels like you're burning a lot of bridges with how you talk on this podcast, but I find myself… liking you more than I used to?" justin.searls.co/casts/
Abusing Rails' content_for to push data attributes up the DOM
(I just thought of this today, and it's probably a terrible idea, but it seems to work. If you have reason to believe this is really stupid, please let me know!)
If you use Hotwired with Rails, you probably find yourself writing a lot of data attributes. This is great, because (similar to HTMX), it makes the DOM the primary source of truth and all that, but it sometimes imposes one of several vexing constraints on Stimulus components:
- Stimulus values must be set on the same element that specifies the controller
(i.e. whatever tag has
data-controller="cheese"must contain all its value attributes, likedata-cheese-smell-value="stinky"). This can be a problem when you'll only have easy access to the value at some later point in your ERB template. You can't just set it on a descendant - A Stimulus controller's targets (as in,
data-cheese-target="swiss") must be descendants of the controller, which can present design challenges when those targets appear in wildly different areas of the DOM, rendered by unrelated templates - Actions will only reach a controller if that controller is an ancestor of
whatever node triggered the event (i.e.
data-action="click->cheese#sniff"only works if it's placed on a descendant of the element withdata-controller="cheese")
I often find myself writing Stimulus components that would be easier to
implement if any of the above three things weren't true, and it can occasionally
lead me to wishing I could just chuck a data attribute near the top of the DOM
in my layout from an individual view or partial to ensure every element involved
shares a certain controller as a common ancestor. The alternatives are all
worse: storing one-off data attributes as values (which don't benefit from
Stimulus nifty Values API),
binding actions to global
events
(@window), or indirect inter-controller
communication.
An example problem
In my particular case, I have a bit of UI in my layout that resembles an iOS navigation bar. That bar will render a search field for certain views that have a number of elements that should be filterable by that search bar. The DOM tree looks like this:
- A top-level layout template:
- A navigation bar partial (that allows customization via
yield :navbar) - A view container containing the layout's primary
yieldto each view. Each view, in turn:- Renders whatever content they need
- [Optional] Configures whether the navigation bar renders a search field
for that page (via
content_for :navbar) - [Optional] Renders a list of elements that should be filterable
- A navigation bar partial (that allows customization via
Say this filtering behavior is governed by a Stimulus controller called
Filterable. This setup raises the question: where should the
data-controller="filterable" attribute go? It can't go in the navigation
bar, because then the target elements to be filtered would not descend from the
controller. It can't go in the view, because then the search bar's events
wouldn't trigger actions on the controller. Of course, it could
go on the layout's <body> tag, but what if only a handful of pages offer
filter functionality? Binding every possible Stimulus controller to the body of
every single page is obviously the wrong answer.
My solution? Abuse the hell out of Action View's content_for and yield
methods. (Here are the relevant Rails
guides
if you're not familiar).
My hacked-up solution
In short, I just encode my desired data attributes from views and partials as
JSON in a call to content_for and then render them upstream to the layout with
yield as attributes on the <body> (or some other common ancestor element).
In this very simple case, the only thing I needed to push up the DOM to a shared
ancestor was a data-controller="filterable", which just required this bit of
magic at the top of the view containing my filterable items:
<% json_content_for :global_data_attrs, {controller: "filterable"} %>
And this update to my layout:
<%= content_tag :body, data: json_content_from(yield(:global_data_attrs)) do %>
<!-- Everything goes here -->
<% end %>
And… boom! The page's body tag now contains data-controller="filterable", which means:
- The items in the view (each with
data-filterable-target="item"set) are now valid targets - The search bar's actions (with
data-action="input->filterable#update") are now reaching the body'sFilterablecontroller.
How it works
Here's how I implemented the helper methods json_content_for and
json_content_from to facilitate this:
# app/helpers/content_for_abuse_helper.rb
module ContentForAbuseHelper
STUPID_SEPARATOR = "|::|::|"
def json_content_for(name, json)
content_for name, json.to_json.html_safe + STUPID_SEPARATOR
end
def json_content_from(yielded_content)
yielded_content.split(STUPID_SEPARATOR).reduce({}) { |memo, json|
memo.merge(JSON.parse(json)) { |key, val_1, val_2|
token_list(val_1, val_2)
}
}
end
end
Take particular note of the call to
token_list
there. Because it's being called in a block passed to Hash#merge, that means
any duplicate data attribute names will have their contents concatenated with
whitespace between them.
This way you could have one view or partial contain:
<% json_content_for :global_data_attrs, {controller: "cheese"} %>
And another with:
<% json_content_for :global_data_attrs, {controller: "meats veggies"} %>
And a layout like the one above will ensure all of your controllers come along for the ride:
<body data-controller="cheese meats veggies">
Cool.
This is a hack
The content_for and yield methods were invented in a simpler time when
developers had the basic decency to keep HTML in their HTML files, CSS in their
CSS files, and JavaScript in their JavaScript files. But thanks to
Tailwind and Stimulus, I find I'm writing more and
more of my styling and behavior in HTML attributes, which is why I contorted
these two methods to do what I wanted.
I'd advise anyone tread lightly with approaches like this. Any time you muck with a higher-order thing from a lower-order thing, you're creating an opportunity for confusion, making caching more difficult, and opening the door to conflicts between unrelated pieces of code.
But, I dunno. Seems to work. 🤷♂️
I bought a new gadget! And it runs Android! And I don't hate it!
Tell me about things you hate and don't hate and I might just read your feelings on air, for others to have opinions about! The e-mail, as always, is podcast@searls.co.
Sources below:
- Ubiquiti UniFi
- Loopback with SSL2+
- This annoying Safari bug
- My first video clip from Breaking Change
- A podcast I will appear on shortly: Vision Pros
- Aaron's punboard
- The EU won't get these new Apple features
- Gruber's take on this matches my own
- The BOOX Palma
- Digital Foundry tells us not to play games on our Windows + Copilot PCs
- Dark Matter
- Godzilla Minus One
- samus.link

