Skip to content

Commit 9261fa6

Browse files
Resolve channel links before room mount
Bring the tested channel route fix from PR #391 into #344 so deep links mount once and keep the parent room through child navigation. Guard metadata callbacks by the route identity captured at render and renew the reviewed dynamic owner hash.
1 parent f69fec7 commit 9261fa6

7 files changed

Lines changed: 139 additions & 74 deletions

File tree

‎apps/web/src/channel-workspace.tsx‎

Lines changed: 19 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,52 @@
11
import { useEffect, useState } from "react";
22

33
import { readChannelRecovery, rememberChannel } from "./channel-recovery";
4-
import { newestDocument } from "./document-actions";
54
import { documentRouteIdentity } from "./document-route-swap";
6-
import { useNavigationDocument } from "./navigation-shell";
75

8-
import type { ComponentType } from "react";
96
import type * as Api from "./api";
107
import type { DocumentRouteIdentity } from "./document-route-swap";
11-
import type {
12-
ChannelSource,
13-
DocumentRouteResolution,
14-
HostedFailure,
15-
HostedLoading,
16-
HostedWorkspaceProps,
17-
} from "./hosted";
8+
import type { ChannelSource, HostedFailure, HostedLoading } from "./hosted";
189

10+
// A channel URL resolves to its document path before any workspace mounts, so the document
11+
// host mounts once under its own route identity instead of replacing a channel-keyed room.
1912
export default function ChannelWorkspace(
20-
{ agent, Failure, Loading, onReady, retryable, source, user }: {
21-
agent: boolean;
13+
{ Failure, Loading, onReady, onResolved, retryable, source, user }: {
2214
Failure: typeof HostedFailure;
2315
Loading: typeof HostedLoading;
24-
onReady?: (key: DocumentRouteIdentity, resolution?: DocumentRouteResolution) => void;
16+
onReady: (key: DocumentRouteIdentity) => void;
17+
onResolved: (key: DocumentRouteIdentity, pathname: string) => void;
2518
retryable: (error: unknown) => boolean;
2619
source: ChannelSource;
2720
user: Api.User;
2821
},
2922
) {
30-
type LoadedWorkspace = {
31-
detail: Api.ChannelDetail;
32-
Workspace: ComponentType<HostedWorkspaceProps>;
33-
};
34-
let [loaded, setLoaded] = useState<LoadedWorkspace>();
3523
let [error, setError] = useState<unknown>();
3624
let [retry, setRetry] = useState(0);
37-
let { channel: navigationChannel } = useNavigationDocument();
3825
let routeKey = documentRouteIdentity(source);
39-
let recovery = readChannelRecovery(user.id, source.id);
26+
let id = source.id;
4027

4128
useEffect(() => {
4229
let active = true;
4330
let controller = new AbortController();
44-
setLoaded(undefined);
4531
setError(undefined);
46-
let prepared = import("./document-loader").then(module =>
47-
module.prepareDocumentLoad({ id: source.id }, controller.signal)
48-
).then(({ detail, pathname }) => ({ canonicalPath: pathname, detail }));
49-
prepared = prepared.then(resolved => {
50-
if (active) {
51-
rememberChannel(user.id, resolved.detail.channel, resolved.detail.repository);
52-
}
53-
return resolved;
54-
});
55-
let workspace = import("./room-workspace").then(module => ({
56-
Workspace: module.RoomWorkspace,
57-
}));
58-
Promise.all([prepared, workspace]).then(([resolved, selected]) => {
59-
if (active) {
60-
setLoaded({ detail: resolved.detail, ...selected });
61-
onReady?.(routeKey, {
62-
canonicalPath: resolved.canonicalPath,
63-
channel: resolved.detail.channel,
64-
routeKey,
65-
});
66-
}
32+
import("./document-loader").then(module =>
33+
module.prepareDocumentLoad({ id }, controller.signal)
34+
).then(({ detail, pathname }) => {
35+
if (!active) return;
36+
rememberChannel(user.id, detail.channel, detail.repository);
37+
onResolved(routeKey, pathname);
6738
}, reason => {
68-
if (active) {
69-
setError(reason);
70-
onReady?.(routeKey);
71-
}
39+
if (!active) return;
40+
setError(reason);
41+
onReady(routeKey);
7242
});
7343
return () => {
7444
active = false;
7545
controller.abort();
7646
};
77-
}, [onReady, retry, routeKey, source, user.id]);
47+
}, [id, onReady, onResolved, retry, routeKey, user.id]);
7848
if (error) {
49+
let recovery = readChannelRecovery(user.id, id);
7950
return (
8051
<Failure
8152
channel={recovery?.channel}
@@ -90,27 +61,5 @@ export default function ChannelWorkspace(
9061
/>
9162
);
9263
}
93-
if (!loaded) return <Loading label="Opening document…" />;
94-
let { detail } = loaded;
95-
let channel = navigationChannel?.id === detail.channel.id
96-
? newestDocument(detail.channel, navigationChannel)
97-
: detail.channel;
98-
let props: HostedWorkspaceProps = {
99-
agent,
100-
archivedAt: channel.archivedAt,
101-
canEdit: !channel.archivedAt && (detail.canEdit || detail.canManage),
102-
canManage: detail.canManage,
103-
description: channel.description,
104-
descriptionRevision: channel.descriptionRevision,
105-
handle: user.login,
106-
label: channel.title,
107-
presentation: { type: "document" },
108-
slug: channel.slug,
109-
updatedAt: channel.updatedAt,
110-
repository: detail.repository,
111-
room: detail.channel.id,
112-
userId: user.id,
113-
};
114-
let Document = loaded.Workspace;
115-
return <Document {...props} />;
64+
return <Loading label="Opening document…" />;
11665
}

‎apps/web/src/document-workspace-host.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ export default function DocumentWorkspaceHost(
315315
}, [onChildClose, presentation]);
316316

317317
let metadataChanged = useCallback((kind: "parent" | "child", metadata: Metadata) => {
318+
if (documentRouteIdentity(routeRef.current) !== routeKey) return;
318319
let current = stateRef.current;
319320
if (current.status === "empty") return;
320321
let target = kind === "parent" ? current.loaded.parent : current.loaded.child;
@@ -336,8 +337,8 @@ export default function DocumentWorkspaceHost(
336337
if (kind === "parent" && routeRef.current.page === "child") {
337338
history.replaceState(rebaseChildHistoryState(history.state, paths.parent), "");
338339
}
339-
onCanonicalPath(layerKey, documentRouteIdentity(routeRef.current), pathname);
340-
}, [layerKey, onCanonicalPath, send]);
340+
onCanonicalPath(layerKey, routeKey, pathname);
341+
}, [layerKey, onCanonicalPath, routeKey, send]);
341342
let parentMetadataChanged = useCallback(
342343
(metadata: Metadata) => metadataChanged("parent", metadata),
343344
[metadataChanged],

‎apps/web/src/hosted.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,9 @@ function DocumentRouteSwap(
275275
onDocumentRouteSettled(requestedRoute.current.routeKey);
276276
}
277277
}, [onDocumentRouteSettled]);
278+
let channelResolved = useCallback((key: DocumentRouteIdentity, pathname: string) => {
279+
if (requestedRoute.current.key === key) onCanonicalPath(pathname);
280+
}, [onCanonicalPath]);
278281
let metadataPath = useCallback((
279282
key: DocumentRouteIdentity,
280283
metadataRouteKey: DocumentRouteIdentity,
@@ -340,10 +343,10 @@ function DocumentRouteSwap(
340343
: (
341344
<Suspense fallback={<Loading label="Opening document…" />}>
342345
<ChannelWorkspace
343-
agent={agent}
344346
Failure={Failure}
345347
Loading={Loading}
346348
onReady={ready}
349+
onResolved={channelResolved}
347350
retryable={retryableChannelFailure}
348351
source={source}
349352
user={user}

‎e2e/hosted.e2e.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createChannel } from "./database";
22
import { authenticate, expect, roomPath, test } from "./room";
33
import { expectInsideViewport, expectNoHorizontalOverflow } from "./responsive";
4+
import { recordRoomMounts } from "./room-mounts";
45
import { installVisualViewport } from "./visual-viewport";
56

67
const score = {
@@ -84,6 +85,43 @@ test("an authenticated user adds a Project and creates its first document", asyn
8485
await expect(activeRoute.locator("[data-plan-decisions-scroll]")).toBeAttached();
8586
});
8687

88+
test("the Add project redirect through a channel link mounts its document once", async ({ baseURL, page, room }) => {
89+
// The room fixture gives the project a saved document to land on.
90+
void room;
91+
let recorded = await recordRoomMounts(page);
92+
let entered: string[] = [];
93+
page.on("framenavigated", frame => {
94+
let match = /^\/channels\/([^/]+)$/.exec(new URL(frame.url()).pathname);
95+
if (frame === page.mainFrame() && match) entered.push(match[1]!);
96+
});
97+
// Holding the project's document list makes the landing redirect use the saved
98+
// document's channel link, as it does when that list is slower than navigation.
99+
await page.route("**/api/repositories/octo-org/score/channels", async route => {
100+
await expect.poll(() => entered.length).toBeGreaterThan(0);
101+
await route.continue();
102+
});
103+
await authenticate(page, `project-redirect-${crypto.randomUUID()}`, baseURL!);
104+
await page.goto("/");
105+
let documentRead = page.waitForResponse(response =>
106+
new URL(response.url()).pathname.startsWith("/api/repositories/octo-org/score/documents/")
107+
);
108+
await repositoryOption(page, "score").click();
109+
110+
await expect(page).toHaveURL(url => url.pathname.startsWith("/documents/octo-org/score/"));
111+
await documentRead;
112+
let landed = entered[0]!;
113+
// Wait until the route swap has settled on a single layer showing the synced document.
114+
await page.waitForFunction(id => {
115+
let layers = document.querySelectorAll(".document-route-swap > [data-content-swap-state]");
116+
return layers.length === 1
117+
&& !!layers[0]!.querySelector(`[data-workspace-room="${id}"] [data-plan-synced]`);
118+
}, landed);
119+
let mounts = await recorded.mounts();
120+
expect(mounts.filter(entry => entry.startsWith("unmount "))).toEqual([]);
121+
expect(mounts.filter(entry => entry === `mount ${landed}`)).toHaveLength(1);
122+
expect(recorded.reads(landed)).toBe(1);
123+
});
124+
87125
test("a signed-out document link returns to the document after OAuth", async ({ baseURL, page, room }) => {
88126
let path = roomPath(room);
89127
await page.goto(path);

‎e2e/research-child-surface.e2e.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { chatInput } from "./chat-input";
22
import { seedChildChannel } from "./database";
33
import { installPointerMedia } from "./pointer-media";
4+
import { recordRoomMounts } from "./room-mounts";
45
import { authenticate, expect, test } from "./room";
56

67
import type { Chat } from "../packages/protocol/index";
@@ -692,6 +693,42 @@ test("a recovered child id enters the canonical anchored child workspace", async
692693
await expect(surface).toHaveCount(0);
693694
});
694695

696+
test("a channel link opens one parent room that a child opens and closes over", async ({ baseURL, page, room, seed }) => {
697+
await seed(PARENT_SOURCE);
698+
let childTitle = `Channel link child ${room.slice(0, 8)}`;
699+
let child = await seedChildChannel(
700+
port(baseURL!),
701+
room,
702+
crypto.randomUUID(),
703+
childTitle,
704+
CHILD_SOURCE,
705+
);
706+
let recorded = await recordRoomMounts(page);
707+
await authenticate(page, "ana", baseURL!);
708+
await page.goto(`/channels/${room}`);
709+
710+
let parentPath = `/documents/octo-org/score/test-${room.slice(0, 8)}`;
711+
await expect(page).toHaveURL(url => url.pathname === parentPath);
712+
let parent = page.locator(`[data-workspace-room="${room}"]`);
713+
await expect(parent.locator("[data-plan-synced]")).toBeAttached();
714+
await parent.evaluate(element => element.setAttribute("data-mount-token", "preserved"));
715+
716+
let surface = page.getByRole("region", { name: `Child document: ${childTitle}` });
717+
await page.getByRole("complementary", { name: "Projects" })
718+
.getByRole("link", { name: childTitle, exact: true }).click();
719+
await expect(page).toHaveURL(url => url.pathname === child.path);
720+
await expect(surface.locator(`[data-workspace-room="${child.id}"]`)).toBeVisible();
721+
await page.keyboard.press("Escape");
722+
await expect(page).toHaveURL(url => url.pathname === parentPath);
723+
await expect(surface).toHaveCount(0);
724+
725+
await expect(parent).toHaveAttribute("data-mount-token", "preserved");
726+
expect((await recorded.mounts()).filter(entry => entry.endsWith(room))).toEqual([
727+
`mount ${room}`,
728+
]);
729+
expect(recorded.reads(room)).toBe(1);
730+
});
731+
695732
test("a child load failure preserves its mounted parent through back and retry", async ({ baseURL, join, page, room, seed }) => {
696733
await seed(PARENT_SOURCE);
697734
let childTitle = `Unreliable source ${room.slice(0, 8)}`;

‎e2e/room-mounts.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { Page } from "@playwright/test";
2+
3+
/** Record every workspace room element that enters or leaves the document. */
4+
export async function recordRoomMounts(page: Page): Promise<{
5+
mounts: () => Promise<string[]>;
6+
reads: (id: string) => number;
7+
}> {
8+
await page.addInitScript(() => {
9+
let log: string[] = [];
10+
Object.defineProperty(window, "__roomMounts", { value: log });
11+
let record = (nodes: NodeList, verb: string) => {
12+
for (let node of nodes) {
13+
if (!(node instanceof Element)) continue;
14+
for (let element of [node, ...node.querySelectorAll("[data-workspace-room]")]) {
15+
let id = element.getAttribute("data-workspace-room");
16+
if (id) log.push(`${verb} ${id}`);
17+
}
18+
}
19+
};
20+
new MutationObserver(records => {
21+
for (let item of records) {
22+
record(item.addedNodes, "mount");
23+
record(item.removedNodes, "unmount");
24+
}
25+
}).observe(document, { childList: true, subtree: true });
26+
});
27+
let channelReads: string[] = [];
28+
page.on("request", request => {
29+
let match = /^\/api\/channels\/([^/]+)$/.exec(new URL(request.url()).pathname);
30+
if (match) channelReads.push(match[1]!);
31+
});
32+
return {
33+
mounts: () =>
34+
page.evaluate(() => [...(window as unknown as { __roomMounts: string[] }).__roomMounts]),
35+
reads: id => channelReads.filter(value => value === id).length,
36+
};
37+
}

‎scripts/design-contract/exceptions/dynamic-web.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
1
6060
]
6161
],
62-
"sourceHash": "18d229959db00ac1a909e34270ef9519466fc48047bffbcf4a01786a7fdc7d40"
62+
"sourceHash": "890443cedd0584c3e57e5295f4fafff7f0bc8231c818cb706c061d097991b49a"
6363
},
6464
{
6565
"file": "apps/web/src/document-rename.tsx",

0 commit comments

Comments
 (0)