Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 7 additions & 38 deletions apps/web/src/layouts/AccountLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -229,47 +229,16 @@ const sidebarSeedJson = JSON.stringify(sidebarProps).replace(/</g, "\\u003c");
/>
<script>
import { onAstroPageLoad, requireElement, resolveSessionGate } from "../lib/account-shell";
import { createElement } from "react";
import { hydrateRoot, type Root } from "react-dom/client";
import { detectIdentifierPrefix } from "../lib/react-island";
import { createIslandMounter } from "../lib/react-island";
import type { ShellSidebarIslandProps } from "../components/shell/ShellSidebar";

let sidebarRoot: Root | null = null;

function teardownSidebar(): void {
if (!sidebarRoot) return;
try {
sidebarRoot.unmount();
} catch {
// Container may already be gone after a body swap.
}
sidebarRoot = null;
}

function readSidebarSeed(): ShellSidebarIslandProps | null {
const el = document.getElementById("shell-sidebar-seed");
if (!el?.textContent) return null;
try {
return JSON.parse(el.textContent) as ShellSidebarIslandProps;
} catch {
return null;
}
}

// Same manual SSR + hydrateRoot mechanism the workspace files table uses
// Same manual SSR + hydrateRoot mechanism the workspace islands use
// (`client:*` is banned repo-wide, see astro.config.mjs).
async function bootSidebar(): Promise<void> {
const mount = document.getElementById("shell-sidebar");
const seed = readSidebarSeed();
if (!mount || !seed) return;
document.addEventListener("astro:before-swap", teardownSidebar, { once: true });
const { ShellSidebar } = await import("../components/shell/ShellSidebar");
if (!document.contains(mount)) return;
teardownSidebar();
sidebarRoot = hydrateRoot(mount, createElement(ShellSidebar, seed), {
identifierPrefix: detectIdentifierPrefix(mount.innerHTML),
});
}
const bootSidebar = createIslandMounter<ShellSidebarIslandProps>({
mountId: "shell-sidebar",
seedId: "shell-sidebar-seed",
load: () => import("../components/shell/ShellSidebar").then((m) => m.ShellSidebar),
});

onAstroPageLoad(() => {
if (!document.getElementById("app")) return;
Expand Down
39 changes: 6 additions & 33 deletions apps/web/src/layouts/AdminLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -143,42 +143,15 @@ const sidebarSeedJson = JSON.stringify(sidebarProps).replace(/</g, "\\u003c");
requireElement,
resolveSessionGate,
} from "../lib/account-shell";
import { createElement } from "react";
import { hydrateRoot, type Root } from "react-dom/client";
import { detectIdentifierPrefix } from "../lib/react-island";
import { createIslandMounter } from "../lib/react-island";
import type { ShellSidebarIslandProps } from "../components/shell/ShellSidebar";

let sidebarRoot: Root | null = null;

function teardownSidebar(): void {
if (!sidebarRoot) return;
try {
sidebarRoot.unmount();
} catch {
// Container may already be gone after a body swap.
}
sidebarRoot = null;
}

// Same manual SSR + hydrateRoot mechanism AccountLayout uses.
async function bootSidebar(): Promise<void> {
const mount = document.getElementById("shell-sidebar");
const seedEl = document.getElementById("shell-sidebar-seed");
if (!mount || !seedEl?.textContent) return;
let seed: ShellSidebarIslandProps;
try {
seed = JSON.parse(seedEl.textContent) as ShellSidebarIslandProps;
} catch {
return;
}
document.addEventListener("astro:before-swap", teardownSidebar, { once: true });
const { ShellSidebar } = await import("../components/shell/ShellSidebar");
if (!document.contains(mount)) return;
teardownSidebar();
sidebarRoot = hydrateRoot(mount, createElement(ShellSidebar, seed), {
identifierPrefix: detectIdentifierPrefix(mount.innerHTML),
});
}
const bootSidebar = createIslandMounter<ShellSidebarIslandProps>({
mountId: "shell-sidebar",
seedId: "shell-sidebar-seed",
load: () => import("../components/shell/ShellSidebar").then((m) => m.ShellSidebar),
});

onAstroPageLoad(() => {
if (!document.getElementById("dashboard")) return;
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/lib/react-island.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { createElement, type ComponentType } from "react";
import { hydrateRoot, type Root } from "react-dom/client";

/**
* Manual-island hydration helper (`client:*` is banned repo-wide; see
* astro.config.mjs, so React components are SSR'd plain and `hydrateRoot`-ed
Expand Down Expand Up @@ -26,3 +29,68 @@ const SSR_USE_ID_RE = /_(r\d+)R_[0-9a-v]/;
export function detectIdentifierPrefix(html: string): string {
return SSR_USE_ID_RE.exec(html)?.[1] ?? "";
}

export interface IslandMounterOptions<P extends object> {
/** id of the element wrapping the SSR'd island markup. */
mountId: string;
/** id of the `<script type="application/json">` carrying the seed props. */
seedId: string;
/**
* Lazily resolves the component to hydrate. Keep the `import()` specifier
* inline (`() => import("./X").then((m) => m.X)`) so the bundler can still
* code-split it — a static top-level import would defeat the point, and a
* dev Fast-Refresh glitch in one island shouldn't block the page's other
* scripts.
*/
load: () => Promise<ComponentType<P>>;
}

/**
* Builds the boot function for a manually-hydrated island, factoring the
* read-seed → teardown-on-swap → lazy-import → `hydrateRoot` lifecycle that
* every mount site otherwise hand-writes. Call once at a `<script>`'s top
* level (so its `root` persists across ClientRouter soft-navs) and invoke the
* returned boot from `onAstroPageLoad` — which fires on first load and on
* every nav, so one registration both mounts and remounts.
*
* Returns without mounting when the element or seed is absent (a page that
* doesn't carry this island), so a layout can call it unconditionally. The
* component must compose its own `IslandErrorBoundary`, as the app's islands
* do, so the hydrated tree matches the SSR'd markup exactly.
*/
export function createIslandMounter<P extends object>(
opts: IslandMounterOptions<P>,
): () => Promise<void> {
let root: Root | null = null;

function teardown(): void {
if (!root) return;
try {
root.unmount();
} catch {
// Container may already be gone after a body swap.
}
root = null;
}

return async function boot(): Promise<void> {
const mount = document.getElementById(opts.mountId);
const seedEl = document.getElementById(opts.seedId);
if (!mount || !seedEl?.textContent) return;
let seed: P;
try {
seed = JSON.parse(seedEl.textContent) as P;
} catch {
return;
}
// Tear down before Astro swaps the body away. {once:true} auto-clears, so
// repeated astro:page-load events from ClientRouter never stack listeners.
document.addEventListener("astro:before-swap", teardown, { once: true });
const Component = await opts.load();
if (!document.contains(mount)) return;
teardown();
root = hydrateRoot(mount, createElement(Component, seed), {
identifierPrefix: detectIdentifierPrefix(mount.innerHTML),
});
};
}
58 changes: 9 additions & 49 deletions apps/web/src/pages/account/workspaces/[name]/files.astro
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,7 @@ const filesSeedJson = JSON.stringify({

<script>
import { onAstroPageLoad } from "../../../../lib/account-shell";
import { createElement } from "react";
import { hydrateRoot, type Root } from "react-dom/client";
import { detectIdentifierPrefix } from "../../../../lib/react-island";
import { createIslandMounter } from "../../../../lib/react-island";
import type { ListingState } from "../../../../components/WorkspaceFileTable";
import type { WorkspaceInfoStatus } from "../../../../lib/workspace-file-row";

Expand All @@ -130,52 +128,14 @@ const filesSeedJson = JSON.stringify({
initialInfo?: WorkspaceInfoStatus;
}

let filesTableRoot: Root | null = null;

function teardown(): void {
if (!filesTableRoot) return;
try {
filesTableRoot.unmount();
} catch {
// Container may already be gone after a body swap.
}
filesTableRoot = null;
}

function readSeed(): FilesSeed | null {
const el = document.getElementById("ws-files-seed");
if (!el?.textContent) return null;
try {
return JSON.parse(el.textContent) as FilesSeed;
} catch {
return null;
}
}

async function boot(): Promise<void> {
const mount = document.getElementById("ws-files-table");
const seed = readSeed();
if (!mount || !seed) return;

// Tear down the previous mount before Astro swaps this page's body away
// (registered once per boot() call — {once:true} auto-clears itself so
// repeated astro:page-load events from ClientRouter never stack listeners).
document.addEventListener("astro:before-swap", teardown, { once: true });

// Lazy, not a static top-level import: keeps a Fast-Refresh/HMR dev
// glitch in this component from blocking the rest of the page's
// scripts — same reasoning every sibling workspace-tab mount uses.
const { WorkspaceFileTable } = await import("../../../../components/WorkspaceFileTable");
if (!document.contains(mount)) return;
teardown();
// `WorkspaceFileTable` already composes `IslandErrorBoundary`
// internally (plan 005 Phase A) — mounting it directly, with no extra
// wrapper, matches the SSR'd tree exactly, so `hydrateRoot` reconciles
// without warnings.
filesTableRoot = hydrateRoot(mount, createElement(WorkspaceFileTable, seed), {
identifierPrefix: detectIdentifierPrefix(mount.innerHTML),
});
}
// `WorkspaceFileTable` composes its own IslandErrorBoundary, so mounting it
// directly matches the SSR'd tree and hydrateRoot reconciles cleanly.
const boot = createIslandMounter<FilesSeed>({
mountId: "ws-files-table",
seedId: "ws-files-seed",
load: () =>
import("../../../../components/WorkspaceFileTable").then((m) => m.WorkspaceFileTable),
});

// astro:page-load fires on the initial load too (same as every sibling
// tab), so this single registration mounts on first load and on nav —
Expand Down
57 changes: 9 additions & 48 deletions apps/web/src/pages/account/workspaces/[name]/screenshots.astro
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,7 @@ const screenshotsSeedJson = JSON.stringify({

<script>
import { onAstroPageLoad } from "../../../../lib/account-shell";
import { createElement } from "react";
import { hydrateRoot, type Root } from "react-dom/client";
import { detectIdentifierPrefix } from "../../../../lib/react-island";
import { createIslandMounter } from "../../../../lib/react-island";
import type { OverviewState } from "../../../../components/ScreenshotsByPath";
import type { WorkspaceInfoStatus } from "../../../../lib/workspace-file-row";

Expand All @@ -126,51 +124,14 @@ const screenshotsSeedJson = JSON.stringify({
initialInfo?: WorkspaceInfoStatus;
}

let screenshotsRoot: Root | null = null;

function teardown(): void {
if (!screenshotsRoot) return;
try {
screenshotsRoot.unmount();
} catch {
// Container may already be gone after a body swap.
}
screenshotsRoot = null;
}

function readSeed(): ScreenshotsSeed | null {
const el = document.getElementById("ws-screenshots-seed");
if (!el?.textContent) return null;
try {
return JSON.parse(el.textContent) as ScreenshotsSeed;
} catch {
return null;
}
}

async function boot(): Promise<void> {
const mount = document.getElementById("ws-screenshots");
const seed = readSeed();
if (!mount || !seed) return;

// Tear down the previous mount before Astro swaps this page's body away
// (registered once per boot() call — {once:true} auto-clears itself so
// repeated astro:page-load events from ClientRouter never stack listeners).
document.addEventListener("astro:before-swap", teardown, { once: true });

// Lazy, not a static top-level import: keeps a Fast-Refresh/HMR dev
// glitch in this component from blocking the rest of the page's
// scripts — same reasoning every sibling workspace-tab mount uses.
const { ScreenshotsByPath } = await import("../../../../components/ScreenshotsByPath");
if (!document.contains(mount)) return;
teardown();
// `ScreenshotsByPath` already composes `IslandErrorBoundary` internally
// (plan 006) — mounting it directly, with no extra wrapper, matches the
// SSR'd tree exactly, so `hydrateRoot` reconciles without warnings.
screenshotsRoot = hydrateRoot(mount, createElement(ScreenshotsByPath, seed), {
identifierPrefix: detectIdentifierPrefix(mount.innerHTML),
});
}
// `ScreenshotsByPath` composes its own IslandErrorBoundary, so mounting it
// directly matches the SSR'd tree and hydrateRoot reconciles cleanly.
const boot = createIslandMounter<ScreenshotsSeed>({
mountId: "ws-screenshots",
seedId: "ws-screenshots-seed",
load: () =>
import("../../../../components/ScreenshotsByPath").then((m) => m.ScreenshotsByPath),
});

// astro:page-load fires on the initial load too (same as every sibling
// tab), so this single registration mounts on first load and on nav —
Expand Down
49 changes: 7 additions & 42 deletions apps/web/src/pages/admin/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -32,50 +32,15 @@ const seedJson = JSON.stringify({ apiOrigin }).replace(/</g, "\\u003c");

<script>
import { onAstroPageLoad } from "../../lib/account-shell";
import { createElement } from "react";
import { hydrateRoot, type Root } from "react-dom/client";
import { detectIdentifierPrefix } from "../../lib/react-island";
import { createIslandMounter } from "../../lib/react-island";
import type { AdminWorkspacesTableProps } from "../../components/admin/AdminWorkspacesTable";

let tableRoot: Root | null = null;

function teardown(): void {
if (!tableRoot) return;
try {
tableRoot.unmount();
} catch {
// Container may already be gone after a body swap.
}
tableRoot = null;
}

function readSeed(): AdminWorkspacesTableProps | null {
const el = document.getElementById("admin-workspaces-seed");
if (!el?.textContent) return null;
try {
return JSON.parse(el.textContent) as AdminWorkspacesTableProps;
} catch {
return null;
}
}

async function boot(): Promise<void> {
const mount = document.getElementById("admin-workspaces-mount");
const seed = readSeed();
if (!mount || !seed) return;

document.addEventListener("astro:before-swap", teardown, { once: true });

// Lazy import (not top-level) mirrors the sibling islands: keeps a dev
// Fast-Refresh glitch in this component from blocking the page's other
// scripts.
const { AdminWorkspacesTable } = await import("../../components/admin/AdminWorkspacesTable");
if (!document.contains(mount)) return;
teardown();
tableRoot = hydrateRoot(mount, createElement(AdminWorkspacesTable, seed), {
identifierPrefix: detectIdentifierPrefix(mount.innerHTML),
});
}
const boot = createIslandMounter<AdminWorkspacesTableProps>({
mountId: "admin-workspaces-mount",
seedId: "admin-workspaces-seed",
load: () =>
import("../../components/admin/AdminWorkspacesTable").then((m) => m.AdminWorkspacesTable),
});

onAstroPageLoad(() => void boot());
</script>
Expand Down
Loading