mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-18 22:42:25 +00:00
Merge pull request #931 from got-feedBack/docs/pane-best-practices
ship-ci / ci (push) Waiting to run
ship-ci / ci (push) Waiting to run
docs(panes): best practices for plugin authors
This commit is contained in:
@@ -465,6 +465,40 @@ window.feedBack.diagnostics.contribute('my_plugin', {
|
|||||||
|
|
||||||
Loaded from `static/diagnostics.js` ASAP in `<head>` so the console-wrap is in place before any other script runs. Available on the `window.feedBack.diagnostics` namespace alongside `snapshotConsole()`, `snapshotHardware()`, `snapshotUa()`, `snapshotLocalStorage()`, `snapshotContributions()`. Keep your payload small (< 100 KB) and don't include secrets — bundles are shared with maintainers.
|
Loaded from `static/diagnostics.js` ASAP in `<head>` so the console-wrap is in place before any other script runs. Available on the `window.feedBack.diagnostics` namespace alongside `snapshotConsole()`, `snapshotHardware()`, `snapshotUa()`, `snapshotLocalStorage()`, `snapshotContributions()`. Keep your payload small (< 100 KB) and don't include secrets — bundles are shared with maintainers.
|
||||||
|
|
||||||
|
### Detachable panes — pop your panel out into its own window
|
||||||
|
|
||||||
|
If your plugin has a floating panel that sits over the player — a mixer, a camera rig, a settings board — you can let the user pop it out into its own OS window and leave it there: while they play, across song switches, on a second monitor, minimized to the tray. Two calls:
|
||||||
|
|
||||||
|
```js
|
||||||
|
feedBack.panes.register({
|
||||||
|
id: 'camera_director',
|
||||||
|
title: 'Camera Director',
|
||||||
|
icon: '🎥',
|
||||||
|
element: () => panelEl, // your existing panel, exactly as it is
|
||||||
|
});
|
||||||
|
feedBack.panes.attachChip(panelEl, 'camera_director');
|
||||||
|
```
|
||||||
|
|
||||||
|
**The host moves your real element.** Not a copy, not a re-render — the actual DOM node, adopted into the pop-out window, keeping its listeners and its closures. Your panel goes on running *your* code against *your* state. It looks and behaves like what was popped out because it **is** what was popped out. Nothing to mirror, nothing to keep in sync.
|
||||||
|
|
||||||
|
The rules below are all things that have already gone wrong. Full contract: **[docs/plugin-panes.md](docs/plugin-panes.md)**.
|
||||||
|
|
||||||
|
- **Your code still runs in the main window.** The element is *displayed* elsewhere; its closures, timers and `document` references still belong to the main realm. That is exactly why everything keeps working — and exactly why `document.body.appendChild(myPopover)` lands in the **main window, not the pane**. Anchor tooltips, popovers and menus to your panel, not to `document.body`. Measure with `el.ownerDocument.defaultView`, never a cached `window`.
|
||||||
|
|
||||||
|
- **Don't hide your panel yourself when it pops out.** Core hides it and leaves a "bring it back" stub. If you also hide it, you will hide the node that just moved — and blank the pane window.
|
||||||
|
|
||||||
|
- **Prefer `hidden` or a class over inline `display` for show/hide.** While popped out, core neutralises *placement* with `.fb-paned` (`position`, `inset`, `width`, `z-index`, `box-shadow`). An inline `display:none` on your panel reasserts itself the moment the pane docks back and the class is removed, so your panel returns invisible.
|
||||||
|
|
||||||
|
- **`element` is a function so it can be resolved late.** Return the *live* node. If you rebuild your panel (Camera Director rebuilds on every mode change), re-run `attachChip` — it returns a `detach()`; call it before re-attaching, and again in your teardown.
|
||||||
|
|
||||||
|
- **`isConnected` does not mean "docked".** A panel sitting in a pane window is very much connected — just not to *this* document. Test `el.ownerDocument === document`, or take the `onHost(hostId, el)` callback.
|
||||||
|
|
||||||
|
- **rAF is throttled while the main window is backgrounded** — and it will be, whenever the user is looking at your pane. Event-driven panels (sliders, buttons) are unaffected. A panel that *animates continuously* may run slowly while it is the only thing on screen.
|
||||||
|
|
||||||
|
- **Don't reach for BroadcastChannel, `postMessage`, or a second copy of your state.** There is one realm and one panel. If you find yourself synchronising, you have misunderstood the model.
|
||||||
|
|
||||||
|
- **Nothing is required.** No panes API on the host → skip both calls, and your panel behaves exactly as it does today.
|
||||||
|
|
||||||
### Keyboard Shortcuts
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
Plugins can register keyboard shortcuts via the global `window.registerShortcut()` function. Shortcuts appear in the `?` help panel.
|
Plugins can register keyboard shortcuts via the global `window.registerShortcut()` function. Shortcuts appear in the `?` help panel.
|
||||||
|
|||||||
+194
-20
@@ -153,28 +153,202 @@ launch. In a **browser** it comes back **docked** — a browser blocks
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Things worth knowing
|
## Best practices
|
||||||
|
|
||||||
1. **Your code still runs in the main window.** The element is displayed in the
|
Every item below is something that has already gone wrong, in this codebase, on
|
||||||
pane window, but its closures, its timers and its `document` references all
|
this feature. They are cheap to get right up front and confusing to diagnose later
|
||||||
still belong to the main realm. That is exactly why everything keeps working —
|
— a broken pane usually *looks* perfect.
|
||||||
but it means a `document.body.appendChild()` inside your panel (a tooltip, a
|
|
||||||
popover) lands in the **main** window, not the pane. Anchor such things to the
|
|
||||||
panel itself, not to `document.body`.
|
|
||||||
|
|
||||||
2. **Chromium throttles a backgrounded window's `requestAnimationFrame`.** While
|
### 1. Your code still runs in the main window
|
||||||
the user is looking at your pane, the main window may be in the background —
|
|
||||||
and your rAF lives there. Event-driven panels (sliders, buttons, presets) are
|
|
||||||
unaffected. A panel that *animates* continuously may run slowly while it's the
|
|
||||||
only thing you're looking at.
|
|
||||||
|
|
||||||
3. **The element goes home exactly where it came from** — same parent, same
|
The element is *displayed* in the pane window, but its closures, its timers and its
|
||||||
position among its siblings. Don't move it yourself while it's popped out.
|
`document` references all still belong to the main realm. **That is precisely why
|
||||||
|
everything keeps working** — and it has one sharp consequence:
|
||||||
|
|
||||||
4. **A pane the user closed with the window's X button is reaped** (a crashed
|
```js
|
||||||
renderer never gets to say goodbye), and your element is docked back. Without
|
// WRONG — lands in the MAIN window, not the pane the user is looking at.
|
||||||
that, your panel would be stranded in a dead document with no way back.
|
document.body.appendChild(myTooltip);
|
||||||
|
|
||||||
5. **Nothing here is required.** On a host without the panes API, `feedBack.panes`
|
// RIGHT — anchored to the panel, so it travels with it.
|
||||||
is undefined, you skip both calls, and your panel behaves exactly as it does
|
panelEl.appendChild(myTooltip);
|
||||||
today.
|
```
|
||||||
|
|
||||||
|
**And every lookup for something inside your panel.** Once the panel has moved,
|
||||||
|
`document.getElementById('my-panel-thing')` returns `null` — so every update it
|
||||||
|
guards silently stops happening, precisely while the user is looking at the panel.
|
||||||
|
No error. Just a UI that quietly goes dead.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// WRONG — null once the panel is popped out.
|
||||||
|
document.getElementById('my-panel-hint').textContent = msg;
|
||||||
|
|
||||||
|
// RIGHT — search FROM the panel; works in either document.
|
||||||
|
panelEl.querySelector('#my-panel-hint').textContent = msg;
|
||||||
|
```
|
||||||
|
|
||||||
|
Elements that live outside your panel (your plugin's *screen*, host chrome) never
|
||||||
|
move, and should keep using `document.getElementById`. Audit which is which — in
|
||||||
|
the stem mixer, four ids were inside the panel and a dozen were not.
|
||||||
|
|
||||||
|
Same for measuring and popovers. `window.innerWidth` is the *main* window's, and a
|
||||||
|
dismiss listener on `window` watches a window the user isn't clicking in. Use
|
||||||
|
`el.ownerDocument` / `el.ownerDocument.defaultView` when you need the window your
|
||||||
|
panel is actually in.
|
||||||
|
|
||||||
|
### 2. Don't hide your panel yourself
|
||||||
|
|
||||||
|
Core hides it and leaves a "bring it back" stub. If your plugin *also* hides it,
|
||||||
|
you are hiding the node that just moved — and the pane window renders nothing.
|
||||||
|
(This is not hypothetical: core's own chip did exactly this, and the first
|
||||||
|
pop-out shipped blank because of it.)
|
||||||
|
|
||||||
|
### 3. Prefer `hidden` or a class for show/hide
|
||||||
|
|
||||||
|
Core makes your panel visible while it's hosted — it clears `hidden`, and clears an
|
||||||
|
inline `display: none` if that's how you hide — and **restores both on dock**. So
|
||||||
|
either style works.
|
||||||
|
|
||||||
|
`hidden` is still the better choice: it composes with everything, and it leaves
|
||||||
|
your panel's `display` mode (`flex`, `grid`, whatever it is) entirely alone. Core
|
||||||
|
deliberately does not override `display` for exactly that reason.
|
||||||
|
|
||||||
|
```js
|
||||||
|
panel.hidden = true; // best
|
||||||
|
panel.style.display = 'none'; // works — core saves and restores it
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. `element` is a function — return the *live* node
|
||||||
|
|
||||||
|
It is resolved when the pane opens, not when you register. Plugins build panels
|
||||||
|
lazily, and rebuild them wholesale (Camera Director rebuilds on every mode
|
||||||
|
change). If you rebuild yours, **re-attach the chip**:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (chipDetach) chipDetach(); // attachChip returns a detach()
|
||||||
|
chipDetach = feedBack.panes.attachChip(panel, PANE_ID, { header: toolsEl });
|
||||||
|
```
|
||||||
|
|
||||||
|
Call `chipDetach()` in your teardown too, or you leave a stub pointing at DOM that
|
||||||
|
no longer exists.
|
||||||
|
|
||||||
|
### 5. `isConnected` lies about a panel that is a pane
|
||||||
|
|
||||||
|
This one has cost more debugging than everything else on this page combined, and
|
||||||
|
it lies in **both directions**.
|
||||||
|
|
||||||
|
**It says `true` when your panel is not here.** A panel sitting in a pane window is
|
||||||
|
`isConnected` — just not to *this* document. Code asking "am I still mounted?" gets
|
||||||
|
`true` and then acts on a panel that is somewhere else entirely.
|
||||||
|
|
||||||
|
**It says `false` when your panel is perfectly fine.** The host *detaches* the
|
||||||
|
element the moment a pop-out starts, before the new window has even loaded. In that
|
||||||
|
gap `isConnected` is `false` — and any code that rebuilds on that basis builds a
|
||||||
|
**second panel**, while the host is still holding the first.
|
||||||
|
|
||||||
|
That second panel is the one your module variables now point at. The one the user
|
||||||
|
can *see* is the original, owned by nobody. So:
|
||||||
|
|
||||||
|
- its close button closes the *other*, invisible panel — "the X doesn't work"
|
||||||
|
- your chip gets re-attached to the impostor — "the pop-out icon vanished"
|
||||||
|
|
||||||
|
Two baffling symptoms, one duplicate, and nothing in the stack trace to suggest it.
|
||||||
|
|
||||||
|
**Ask the pane system, not the DOM.** It knows where your element is:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function paneOwnsPanel() {
|
||||||
|
const panes = window.feedBack && window.feedBack.panes;
|
||||||
|
return !!(panes && panes.isOpen && panes.isOpen(MY_PANE_ID));
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Is my panel gone?" — not "is it in this document?"
|
||||||
|
if (panel && (panel.isConnected || paneOwnsPanel())) return panel; // alive; possibly elsewhere
|
||||||
|
```
|
||||||
|
|
||||||
|
Every `isConnected` check on a panel that can be a pane needs this. In the stem
|
||||||
|
mixer that was `ensureMixerPanel()` (which rebuilt) *and* the MutationObserver's
|
||||||
|
fast path (which decided the UI was unmounted and swept on every mutation).
|
||||||
|
|
||||||
|
For "which document is it in right now", use `el.ownerDocument === document`, or
|
||||||
|
take the optional `onHost(hostId, el)` callback, which fires on both moves.
|
||||||
|
|
||||||
|
### 6. If your plugin can be re-injected, it must be able to remove itself
|
||||||
|
|
||||||
|
The host may run your script more than once — a screen re-entry, a version change.
|
||||||
|
Without a teardown, the second run builds a second panel while the first one is
|
||||||
|
still on screen, and every module variable in the new instance points at the new,
|
||||||
|
invisible one. The user clicks the panel they can see; nothing happens.
|
||||||
|
|
||||||
|
Everything stateful duplicates: observers, timers, listeners. And one thing is
|
||||||
|
worse than duplicated — **your pane registration**:
|
||||||
|
|
||||||
|
```js
|
||||||
|
panes.register({ id, element: () => panel }); // resolved LAZILY, at open time
|
||||||
|
```
|
||||||
|
|
||||||
|
First registration wins, so a stale one hands the host `panel` from a **dead
|
||||||
|
instance**. Popping out then moves a panel nobody owns.
|
||||||
|
|
||||||
|
So publish a teardown handle and call it at the top of your script:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (window.__myPluginInstance?.destroy) {
|
||||||
|
try { window.__myPluginInstance.destroy(); } catch (e) { /* tear down what we can */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__myPluginInstance = {
|
||||||
|
destroy() {
|
||||||
|
observer?.disconnect();
|
||||||
|
clearTimeout(myTimer);
|
||||||
|
chipDetach?.(); // attachChip() returned this
|
||||||
|
panes?.unregister?.(MY_PANE_ID); // ← the one people forget
|
||||||
|
document.querySelectorAll('#my-panel').forEach((n) => n.remove());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Belt and braces: when you build your panel, remove any node carrying its id that
|
||||||
|
isn't yours. A zombie panel is worse than no panel — it looks alive and does
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
### 7. Expect rAF to be throttled while your pane has focus
|
||||||
|
|
||||||
|
Chromium throttles a **backgrounded** window's `requestAnimationFrame` — and the
|
||||||
|
main window is exactly what's backgrounded while the user is looking at your pane.
|
||||||
|
Your rAF lives in the main window.
|
||||||
|
|
||||||
|
Event-driven panels (sliders, buttons, presets) don't care. A panel that
|
||||||
|
*animates continuously* may run slowly precisely when it's the only thing on
|
||||||
|
screen. Drive such animation from data you already have, or accept the stutter.
|
||||||
|
|
||||||
|
### 8. Don't synchronise anything
|
||||||
|
|
||||||
|
No `BroadcastChannel`, no `postMessage`, no second copy of your state, no mirrored
|
||||||
|
UI. There is **one** realm and **one** panel. If you find yourself writing sync
|
||||||
|
code, you have misunderstood the model — the whole point is that there is nothing
|
||||||
|
to sync.
|
||||||
|
|
||||||
|
### 9. Nothing here is required
|
||||||
|
|
||||||
|
On a host without the panes API, `feedBack.panes` is `undefined`. Skip both calls
|
||||||
|
and your panel behaves exactly as it does today. Guard, don't depend:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const panes = window.feedBack && window.feedBack.panes;
|
||||||
|
if (!panes || typeof panes.register !== 'function') return;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Things core guarantees
|
||||||
|
|
||||||
|
- **The element goes home exactly where it came from** — same parent, same position
|
||||||
|
among its siblings. Don't move it yourself while it's popped out.
|
||||||
|
- **It comes home alive.** Core evacuates the element *before* the pane window's
|
||||||
|
document is destroyed. (Get this wrong — dock after the window dies — and the
|
||||||
|
node returns looking perfect with every listener in its subtree silently gone.
|
||||||
|
That bug is why this section exists.)
|
||||||
|
- **A pane window the user closes, or that crashes, is reaped** and the element
|
||||||
|
docked back. Your panel is never stranded in a dead document.
|
||||||
|
- **The app's stylesheets are copied into the pane window**, so your panel looks
|
||||||
|
identical — including your plugin's own `styles` sheet.
|
||||||
|
|||||||
Reference in New Issue
Block a user