feat: show a raw app's document.title in the page tab (#11747)

* feat: show a raw app's document.title in the page tab

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: drop unrelated formatting changes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: restore the page title only while the tab shows the app's

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-10-14 00:06:37 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b7b3306195
commit 5153a5e7b8
7 changed files with 53 additions and 5 deletions
+11 -1
View File
@@ -1015,7 +1015,6 @@ fn raw_app_wrapper_html(secret: &str) -> String {
<html>
<head>
<meta charset="UTF-8" />
<title>App</title>
<link rel="stylesheet" href="./__SECRET__.css" />
<script>
(function () {
@@ -1067,6 +1066,17 @@ fn raw_app_wrapper_html(secret: &str) -> String {
history.pushState = function () { _ps.apply(this, arguments); notifyParent(); };
history.replaceState = function () { _rs.apply(this, arguments); notifyParent(); };
// The parent shows the app's document.title in its tab. This document has no
// <title> of its own: document.title reads the first one, so a placeholder would
// shadow the one a framework (React 19, react-helmet) appends.
var lastTitle = '';
new MutationObserver(function () {
var t = document.title;
if (t === lastTitle) return;
lastTitle = t;
try { window.parent.postMessage({ type: 'windmill:title', title: t }, '*'); } catch (_) {}
}).observe(document.head, { childList: true, subtree: true, characterData: true });
// ctx handshake: the embedding parent hands us the user context (and any
// persisted storage) before we evaluate the bundle, so `window.ctx` is set
// synchronously when the bundle runs. The bundle <script> is injected only
+2
View File
@@ -6681,6 +6681,8 @@ Svelte (\`index.ts\`): \`mount(App, { target: document.getElementById('root')! }
**Never replace the entrypoint with a bare component** (\`export default function App() { ... }\` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts \`App\` into \`#root\`.
**Set the browser tab title with \`document.title = '...'\`** (or a \`<title>\` the framework renders into \`<head>\`). The app has no \`index.html\` of its own, so a \`<title>\` written there is never served; the deployed app's page shows whatever \`document.title\` holds, and follows it when it changes.
**Always begin every React file (\`.tsx\`/\`.jsx\`) that uses JSX with \`import React from 'react'\`.** esbuild uses the classic JSX transform, so \`React\` must be in scope wherever JSX appears — a missing import compiles fine but throws \`React is not defined\` at runtime, leaving a blank screen.
### Generated bindings (\`wmill.d.ts\` / \`wmill.ts\`)
@@ -27,9 +27,10 @@
* (publish flow) needs it to read the app's DOM, which is only possible on
* the unsandboxed path. */
oniframe?: (iframe: HTMLIFrameElement | undefined) => void
/** Mirror the app's own route into the page URL's hash, and start from it. Off where
* the app is embedded in a page that is not its own — an AI session preview tab —
* since there the hash belongs to that page and to whichever app was opened first. */
/** Mirror the app's own route into the page URL's hash, and start from it, and its
* `document.title` into the page's. Off where the app is embedded in a page that is
* not its own — an AI session preview tab — since there the hash and title belong to
* that page and to whichever app was opened first. */
syncHashToUrl?: boolean
}
@@ -241,6 +242,19 @@
}
})
// The page's own title, put back when the app clears its title or unmounts. Only
// while the tab still shows the app's: anything else is the page's newer title.
let pageTitle = ''
let appTitle: string | undefined = undefined
function setAppTitle(title: string) {
if (document.title !== appTitle) pageTitle = document.title
appTitle = title.trim().slice(0, 200) || pageTitle
document.title = appTitle
}
onMount(() => () => {
if (appTitle !== undefined && document.title === appTitle) document.title = pageTitle
})
$effect(() => {
function handleMessage(event: MessageEvent) {
const data = event.data
@@ -287,6 +301,8 @@
if (syncHashToUrl && window.location.hash !== newHash) {
history.replaceState(null, '', newHash || window.location.pathname)
}
} else if (data?.type === 'windmill:title') {
if (syncHashToUrl) setAppTitle(typeof data.title === 'string' ? data.title : '')
} else if (runnerFailed) {
refuseRequest(event)
} else if (!runner) {
+15 -1
View File
@@ -294,7 +294,6 @@ export function unsandboxedRawAppHtml(
<html>
<head>
<meta charset="UTF-8" />
<title>App</title>
<!-- Before the app's own stylesheet, so a bundle that wants a margin can still set one: the
browser default of 8px would otherwise frame every app with a gap it never asked for. -->
<style>html, body { margin: 0; }</style>
@@ -319,6 +318,21 @@ export function unsandboxedRawAppHtml(
history.pushState = function () { _ps.apply(this, arguments); notifyParent(); };
history.replaceState = function () { _rs.apply(this, arguments); notifyParent(); };
setTimeout(notifyParent, 0);
// The parent shows the app's document.title in its tab. This document has no
// <title> of its own: document.title reads the first one, so a placeholder would
// shadow the one a framework (React 19, react-helmet) appends.
var lastTitle = '';
new MutationObserver(function () {
var t = document.title;
if (t === lastTitle) return;
lastTitle = t;
try {
if (window.parent !== window) {
window.parent.postMessage({ type: 'windmill:title', title: t }, '*');
}
} catch (_) {}
}).observe(document.head, { childList: true, subtree: true, characterData: true });
})();
</script>
</head>
+2
View File
@@ -955,6 +955,8 @@ Svelte (\`index.ts\`): \`mount(App, { target: document.getElementById('root')! }
**Never replace the entrypoint with a bare component** (\`export default function App() { ... }\` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts \`App\` into \`#root\`.
**Set the browser tab title with \`document.title = '...'\`** (or a \`<title>\` the framework renders into \`<head>\`). The app has no \`index.html\` of its own, so a \`<title>\` written there is never served; the deployed app's page shows whatever \`document.title\` holds, and follows it when it changes.
**Always begin every React file (\`.tsx\`/\`.jsx\`) that uses JSX with \`import React from 'react'\`.** esbuild uses the classic JSX transform, so \`React\` must be in scope wherever JSX appears — a missing import compiles fine but throws \`React is not defined\` at runtime, leaving a blank screen.
### Generated bindings (\`wmill.d.ts\` / \`wmill.ts\`)
@@ -311,6 +311,8 @@ Svelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`.
**Never replace the entrypoint with a bare component** (`export default function App() { ... }` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts `App` into `#root`.
**Set the browser tab title with `document.title = '...'`** (or a `<title>` the framework renders into `<head>`). The app has no `index.html` of its own, so a `<title>` written there is never served; the deployed app's page shows whatever `document.title` holds, and follows it when it changes.
**Always begin every React file (`.tsx`/`.jsx`) that uses JSX with `import React from 'react'`.** esbuild uses the classic JSX transform, so `React` must be in scope wherever JSX appears — a missing import compiles fine but throws `React is not defined` at runtime, leaving a blank screen.
### Generated bindings (`wmill.d.ts` / `wmill.ts`)
+2
View File
@@ -70,6 +70,8 @@ Svelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`.
**Never replace the entrypoint with a bare component** (`export default function App() { ... }` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts `App` into `#root`.
**Set the browser tab title with `document.title = '...'`** (or a `<title>` the framework renders into `<head>`). The app has no `index.html` of its own, so a `<title>` written there is never served; the deployed app's page shows whatever `document.title` holds, and follows it when it changes.
**Always begin every React file (`.tsx`/`.jsx`) that uses JSX with `import React from 'react'`.** esbuild uses the classic JSX transform, so `React` must be in scope wherever JSX appears — a missing import compiles fine but throws `React is not defined` at runtime, leaving a blank screen.
### Generated bindings (`wmill.d.ts` / `wmill.ts`)