# turbo-is-frame

`<turbo-frame>` behavior for elements that cannot host one, such as table rows.

```haml
%tr{id: dom_id(import), data: {controller: 'is-frame', is_frame_src_value: import_path(import)}}
```

This stands in for what would otherwise be written as `<tr is="turbo-frame" src="…">`. Customized built-in elements are not implemented in WebKit, so `is=""` is unavailable, and wrapping the row in a `<turbo-frame>` is not an option either: the HTML parser moves the wrapper out of the table. This package gives the row itself the parts of the frame protocol that matter, and a Stimulus controller supplies the machinery a custom element would have provided.

Use it where a `<turbo-frame>` wrapper cannot go.

- Where the parser destroys it: inside table markup, inside `select`.
- Where it breaks the content model: `ul` and `ol` children, `dl`.
- Where it breaks layout: a direct child of a grid or flex container.
- Where position carries meaning: `summary`, `legend`, `caption`.

The host element must be able to hold an id and to hold its children as ordinary markup. Void elements, raw text elements (`textarea`, `script`, `style`) and SVG or MathML content will not work.

## Requirements

Turbo 8 or later. Stimulus 3.2 or later for the controller; the core needs neither.

## Installation

Turbo is passed in rather than imported, so one call registers it for every setup. See [Why Turbo is injected](#why-turbo-is-injected) for the reason.

### With a bundler

```
npm install turbo-is-frame
```

```js
// app/javascript/controllers/index.js
import { Application } from "@hotwired/stimulus"
import * as Turbo from "@hotwired/turbo"
import IsFrame, { useTurbo } from "turbo-is-frame"

const application = Application.start()

useTurbo(Turbo)
application.register("is-frame", IsFrame)
```

### With import maps

```
bin/importmap pin turbo-is-frame
```

```js
// app/javascript/controllers/index.js
import { Application } from "@hotwired/stimulus"
import { Turbo } from "@hotwired/turbo-rails"
import IsFrame, { useTurbo } from "turbo-is-frame"

window.Stimulus = Application.start()

useTurbo(Turbo)
Stimulus.register("is-frame", IsFrame)
```

No pin for `@hotwired/turbo` is needed, and none should be added: pinning it to a second copy of Turbo loads a second `customElements.define("turbo-frame", …)` and throws.

### With lazily loaded controllers

Applications generated with `stimulus-loading` register controllers by filename, leaving nowhere to call `useTurbo`. Put both in the file that Stimulus discovers.

```js
// app/javascript/controllers/is_frame_controller.js
import { Turbo } from "@hotwired/turbo-rails"
import IsFrame, { useTurbo } from "turbo-is-frame"

useTurbo(Turbo)
export default IsFrame
```

## Usage

```html
<tr id="row_1" data-controller="is-frame" data-is-frame-src-value="/rows/1" data-is-frame-complete-value="true">
  <td>Server rendered contents</td>
  <td><a href="/rows/1/edit" data-turbo-frame="_top">Edit</a></td>
</tr>
```

### Values

| Value | Default | Meaning |
| --- | --- | --- |
| `src` | none | Navigation target. Writing it navigates the frame, exactly as setting `src` on a `<turbo-frame>` does. |
| `complete` | `false` | Mark contents the server already rendered, so the frame does not fetch them again on connect. Equivalent to the `complete` attribute of `<turbo-frame>`. **Set this whenever `src` is present in server-rendered markup**, or every such element fetches itself once on page load. |
| `loading` | `"eager"` | `"lazy"` defers the load until the element is first visible. |
| `disabled` | `false` | The frame ignores navigation. |

### Links and forms inside the frame

Navigation originating inside the element is captured and rendered into it, as with a real frame. A link meant to leave the frame needs `data-turbo-frame="_top"`; without it the click is captured and the frame tries to render a whole page into itself.

`data-turbo-frame="some-id"` delegates to another element hosting this controller. If the id belongs to a real `<turbo-frame>`, the click is left to Turbo.

### Events

Events are named after the identifier the controller is registered as. Registered as `is-frame`, it emits:

| Event | Detail | When |
| --- | --- | --- |
| `is-frame:load` | `{ src, response }` | Contents were replaced. |
| `is-frame:missing` | `{ response, visit }` | The response carried no element with this id. Cancelable; see below. |
| `is-frame:error` | `{ error }` | The request failed before a response arrived. |

Being ordinary DOM events they can be wired with `data-action`, which is how a separate controller can drive reloads without reaching into this one:

```haml
%tbody{data: {controller: 'polling'}}
  %tr{data: {controller: 'is-frame', action: 'polling:reload->is-frame#reloadIfIdle'}}
```

### Actions

`reload()` fetches `src` again. `reloadIfIdle()` does nothing while a request is in flight, which is what repeated drivers such as polling want: `reload()` cancels the request in flight, so a response slower than the polling interval would be interrupted every time and never complete.

## Differences from `<turbo-frame>`

- **Frame missing.** Turbo writes "Content missing" into the frame and throws `TurboFrameMissingError`. Emptying a host element such as a table row wrecks the layout, so the children are kept. When the response was redirected, which is what an expired session looks like, the whole page visits that response instead. Cancel `is-frame:missing` to suppress both.
- **Redirect tracking.** Turbo assigns the response URL to `src` before rendering. This package assigns it only after the element was found, so that a redirect to a sign-in page does not leave its URL in `src` and poison every later reload.
- **History.** `data-turbo-action` is not implemented, so frame navigation is never promoted to a visit.
- **Events.** `turbo:frame-load` and `turbo:before-frame-render` are not emitted; the events above take their place. The fetch layer is Turbo's own `FetchRequest`, so `turbo:before-fetch-request`, `turbo:before-fetch-response` and `turbo:fetch-request-error` behave as they do for a real frame.
- **`data-turbo-method`.** Turbo captures those links at the document level before this controller sees them, so they are not scoped to the frame. Use `button_to` (a form) instead.
- **Not carried over.** `data-turbo-prefetch`, `data-turbo-confirm`, the progress bar, automatic disabling of submit buttons, `turbo-visit-control`, and changing `loading` or `disabled` after connect.
- **Rendering.** Children are replaced outright. Morphing is not applied.
- **Non-HTML responses.** Turbo ignores them silently; here they take the missing path, so a driver is not left waiting on nothing.

This is a reimplementation from the public documentation, the HTML Standard and observable behavior, not a port of Turbo's source. Edge cases may differ.

## Why Turbo is injected

The pieces this package needs (`FetchRequest` and friends) live in Turbo's `src/http` module.

- They are named exports of the `@hotwired/turbo` npm package.
- They are **not** on `window.Turbo`, which carries `src/core` plus `StreamActions` only.
- Rails applications using import maps resolve `@hotwired/turbo-rails` to a bundle whose sole export is the `Turbo` namespace, and have no `@hotwired/turbo` entry at all.

So no single import statement reaches them everywhere. Passing the namespace in once does, and the value has the same properties either way, because turbo-rails re-exports `import * as Turbo from "@hotwired/turbo"` unchanged. `useTurbo` validates what it is given and names what is missing.

## The core without Stimulus

Stimulus is one binding, not the substance. `Frame` holds the protocol (fetching, rendering, navigation interception) and takes a host object supplying attribute reflection, lifecycle, instance lookup and event dispatch. Turbo splits the same way, with `FrameElement` delegating to `FrameController`.

```js
import { Frame, useTurbo } from "turbo-is-frame/core"
```

A host provides:

| Member | Kind | Purpose |
| --- | --- | --- |
| `element` | property | The element whose children are replaced. |
| `src` | get and set | Navigation target; the host stores it. |
| `complete` | get and set | Whether the current children are up to date. |
| `loading` | get | `"eager"` or `"lazy"`. |
| `disabled` | get | Whether navigation is ignored. |
| `findFrame(id)` | method | The `Frame` hosted by that element id, or `null`. |
| `notify(name, detail, options)` | method | Dispatch an event and return it. |

State belonging to the document is read through the host on every access and never cached, so there is one source of truth.

Two obligations are easy to miss when writing a host. Change notifications that arrive before `connect()` describe markup the server wrote and must not be forwarded to `sourceURLChanged()`; `connect()` decides the initial load, guarding on `complete`. And `notify` must return something with `defaultPrevented` for `is-frame:missing` to be cancelable.

## Development

```
npm install
npx playwright install
npm test
```

Tests run in Chromium and WebKit through `@web/test-runner`. A real browser rather than a DOM emulation is deliberate: what this package exists for is a parsing detail, so the parser under test has to be a real one.

## License

MIT, copyright Toru KAWAMURA.
