├── .gitignore ├── CHANGELOG.md ├── README.md ├── package-lock.json ├── package.json └── src └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## Upcoming 6 | 7 | ## 1.2.0 - 2023-09-06 8 | 9 | - Resolve promise in `shadowRootAttached` immediately if there's no DSD template to wait for. 10 | 11 | ## 1.1.0 - 2023-04-10 12 | 13 | - Switch from the outdated `shadowroot` attribute to the new standard `shadowrootmode`. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Turbo Shadow 2 | 3 | > [!WARNING] 4 | > I've migrated away from using the Hotwire stack (see [this blog post](https://www.bridgetownrb.com/future/road-to-bridgetown-2.0-escaping-burnout/#the-37signals-problem) for the reasons why I now avoid 37signals-owned codebases), so I don't intend to develop this utility any further. I'll be happy to accept PRs and cut a new release, but beyond that, consider this plugin "done". 5 | 6 | ---- 7 | 8 | Provides event handling and an HTMLElement mixin for [Declarative Shadow DOM](https://web.dev/declarative-shadow-dom) support in [Hotwire Turbo](https://turbo.hotwired.dev). 9 | 10 | Requires Turbo 7.2 or higher. 11 | 12 | ## Quick Install 13 | 14 | Add the NPM library to your project: 15 | 16 | ```sh 17 | npm i turbo-shadow 18 | # or 19 | yarn add turbo-shadow 20 | ``` 21 | 22 | Add this to your JavaScript entrypoint (likely `index.js`) right after you import Turbo: 23 | 24 | ```js 25 | import * as TurboShadow from "turbo-shadow" 26 | ``` 27 | 28 | And add this to your HTML head (unfortunately Turbo's client-side caching will strip out all shadow roots). 29 | 30 | ```html 31 | 32 | ``` 33 | 34 | Now when you write a web component by subclassing `HTMLElement` (or some subclass of that), you can use the `ShadowRootable` mixin along with the `shadowRootAttached` promise to ensure by the time you run code within your `connectedCallback`, the shadow root with server-sent declarative markup has already been attached. 35 | 36 | ```html 37 | 38 | 39 | 43 | 44 |

Greetings from Turbo

45 |
46 | ``` 47 | 48 | ```js 49 | // Client-side JavaScript 50 | import { ShadowRootable } from "turbo-shadow" 51 | 52 | class TestDSDElement extends ShadowRootable(HTMLElement) { 53 | async connectedCallback() { 54 | // Wait for the promise that the shadow root has been attached 55 | await this.shadowRootAttached 56 | 57 | // Shadow DOM markup is now loaded and working for the component 58 | console.log("The shadow root has been attached", this.shadowRoot.innerHTML) 59 | } 60 | } 61 | customElements.define("test-dsd", TestDSDElement) 62 | ``` 63 | 64 | Something to note: the client-side JavaScript component definition is actually optional. With Declarative Shadow DOM, you can write HTML-only shadow roots and that's perfectly fine. In fact, you don't even need to use custom elements! You can still get all the benefits of encapsulated styles and DOM that's hidden away from the parent document using most built-in HTML elements. This totally works: 65 | 66 | ```html 67 |
68 | 77 |
78 | ``` 79 | 80 | Keep reading for further details… 81 | 82 | ## Rationale for Turbo Shadow 83 | 84 | [Hotwire Turbo](https://turbo.hotwired.dev) is an excellent JavaScript library that can take your MPA (Multi-Page Application) and make it feel more like an SPA (Single-Page Application): with fast page changes which you can augment with slick CSS transitions, frame-like support for loading and updating specific regions of a page in real-time, and a feature called Streams which can surgically alter the DOM from server-driven events. 85 | 86 | [Declarative Shadow DOM](https://web.dev/declarative-shadow-dom) (DSD) is an emerging spec for Web Components which lets you define the "shadow DOM" template for a component using server-rendered HTML. So instead of writing out this: 87 | 88 | ```html 89 |

Hello World from a Web Server

90 | 91 | 92 | 93 | 94 | ``` 95 | 96 | You can write out this (or generate it automatically from a template engine of some kind): 97 | 98 | ```html 99 |

Hello World from a Web Server

100 | 101 | 102 | 113 | 114 | ``` 115 | 116 | **You would think that Declarative Shadow DOM and Turbo would be a match made in heaven! Both resolve around the centrality of HTML. But…you would be wrong. 😭** 117 | 118 | First of all, while DSD is natively supported in evergreen browsers, Turbo currently isn't directly compatible with the native DSD support, because standard HTML parsing methods in JavaScript don't support DSD for security reasons. For example, if you were to run this: 119 | 120 | ```js 121 | (new DOMParser()).parseFromString(htmlContainingDSD, "text/html") 122 | ``` 123 | 124 | Any shadow root templates in the `htmlContainingDSD` would be ignored…aka they'd just remain inert templates in the output node tree. To get real attached shadow DOM roots, you'd need to switch to `parseHTMLUnsafe` API which is fairly new and not widely supported yet. 125 | 126 | Will Turbo itself get updated in the future to support this? Possibly. Until that time, you will need a Turbo-specific polyfill to handle full DSD support. 127 | 128 | **Introducing: Turbo Shadow.** 😎 129 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "turbo-shadow", 3 | "version": "1.2.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "turbo-shadow", 9 | "version": "1.2.0", 10 | "license": "MIT" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "turbo-shadow", 3 | "version": "1.2.0", 4 | "description": "Provides event handling and an HTMLElement mixin for Declarative Shadow DOM in Hotwire Turbo.", 5 | "main": "src/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "release:patch": "npm version patch && npm publish && git push --follow-tags", 9 | "release:minor": "npm version minor && npm publish && git push --follow-tags", 10 | "release:major": "npm version major && npm publish && git push --follow-tags", 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/whitefusionhq/turbo-shadow.git" 16 | }, 17 | "keywords": [ 18 | "Turbo", 19 | "Declarative Shadow DOM", 20 | "Web Components" 21 | ], 22 | "author": "Jared White", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/whitefusionhq/turbo-shadow/issues" 26 | }, 27 | "homepage": "https://github.com/whitefusionhq/turbo-shadow#readme" 28 | } 29 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export const ShadowRootable = (superClass) => class extends superClass { 2 | #shadowRootConnected; 3 | 4 | attachedShadowRootCallback() { 5 | this.#shadowRootConnected?.() 6 | } 7 | 8 | get shadowRootAttached() { 9 | if (this.shadowRoot || !this.querySelector("template[shadowrootmode]")) return Promise.resolve() 10 | 11 | const promise = new Promise(resolve => this.#shadowRootConnected = resolve) 12 | 13 | return promise 14 | } 15 | } 16 | 17 | export function attachShadowRoots(root, callback = null) { 18 | const shadowNodes = [] 19 | root.querySelectorAll("template[shadowrootmode]").forEach(template => { 20 | let shadowRoot 21 | const node = template.parentNode 22 | const mode = template.getAttribute("shadowrootmode") 23 | try { 24 | shadowRoot = node.attachShadow({ mode }) 25 | shadowRoot.appendChild(template.content) 26 | template.remove() 27 | shadowNodes.push(node) 28 | } catch (err) { 29 | shadowRoot = node.shadowRoot 30 | } 31 | if (shadowRoot) { 32 | attachShadowRoots(shadowRoot).forEach(childNode => shadowNodes.push(childNode)) 33 | } 34 | }) 35 | 36 | if (callback) { 37 | shadowNodes.forEach(node => callback(node)) 38 | } else { 39 | return shadowNodes 40 | } 41 | } 42 | 43 | export function attachShadowRootsAndNotify(root) { 44 | attachShadowRoots(root, node => node.attachedShadowRootCallback?.()) 45 | } 46 | 47 | 48 | document.documentElement.addEventListener("turbo:load", (event) => { 49 | attachShadowRootsAndNotify(event.target) 50 | }) 51 | 52 | document.documentElement.addEventListener("turbo:frame-load", (event) => { 53 | attachShadowRootsAndNotify(event.target) 54 | }) 55 | 56 | document.documentElement.addEventListener("turbo:before-stream-render", async (event) => { 57 | const prevRender = event.detail.render 58 | event.detail.render = (async (newElement) => { 59 | await prevRender(newElement) 60 | event.target.targetElements.forEach(el => { 61 | attachShadowRootsAndNotify(el) 62 | }) 63 | }) 64 | }) 65 | --------------------------------------------------------------------------------