Problem
A signed-in user can see "Join Channel" in place of the composer. This happens when the profile loads after the heading chat opened. The chat runs its join step only once, when it opens.
On 14ab7f9c1. Paths are under apps/webapp/src/.
useChannelMetadata reads the user id once, inside its load effect (components/chatroom/hooks/useChannelMetadata.ts:24). The effect deps are the channel id and the workspace id (:76). The provider key is the heading id (components/chatroom/Chatroom.tsx:38). So nothing runs the effect again when the profile loads.
- With no user id, the effect skips the channel create step (
:31) and the join step (:58). bootstrapChannel then stores the member flag isUserChannelMember from the server value, which is false (stores/chat/bootstrapStore.ts:25).
- While the profile is missing,
AccessControl shows the composer (components/chatroom/components/ChannelComposer/ChannelComposer.tsx:39). When the profile loads, a PUBLIC chat with the member flag false shows "Join Channel" instead (:55).
- The only other writer of the member flag is the manual join (
components/chatroom/hooks/useJoinChannel.ts:19). For an account that is not yet a member, one click on "Join Channel" brings the composer back. For an account that is already a member, the click fails, and the composer comes back only when the chat opens again. The user should not need that click.
Two paths open the chat before the profile loads:
- Google One Tap signs in without a reload, on desktop only (
components/pages/document/DocumentPage.tsx:56). It sets the profile at components/GoogleOneTapAuth.tsx:54, through components/auth/applySignedInProfile.ts:28. The pad provider is keyed on the document id only (hooks/useYdocAndProvider.ts:250), so the open chat stays mounted.
- On a cold load, the session is set before the profile fetch ends (
hooks/useOnAuthStateChange.ts:69-71). A chat deep link waits for the document, not the profile (hooks/useCheckUrlAndOpenHeadingChat.ts:41). This is a race. It needs a profile fetch that is slower than the document sync plus the 800 ms delay (hooks/useCheckUrlAndOpenHeadingChat.ts:53-59). Here the session is present, so only an account that is not yet a member sees "Join Channel".
In the One Tap path, even an account that is already a member of that chat sees "Join Channel". The first load ran as a visitor, so the server returned false.
Steps to reproduce
- Use Google Chrome 117 or later on desktop, signed in to a Google account. Sign out of docs.plus.
- Open a pad link that carries
?chatroom=<heading id>, for a heading chat that has messages. The link opens the chat without a click, so the One Tap prompt stays open.
- Wait for the chat to open. The composer shows.
- Sign in with the Google One Tap prompt. The page does not reload.
- See "Join Channel" in place of the composer.
Acceptance criteria
Agent Brief
Category: bug
Summary: When the profile loads after a chat opens, the chat must still run its skipped write steps, so a signed-in user gets the composer.
Current behavior:
useChannelMetadata reads the user id once, inside its load effect. The effect does not run again when the profile loads. With no user id, it skips upsertChannel and joinChannel. bootstrapChannel stores isUserChannelMember as false. When the profile loads, AccessControl swaps the composer for the "Join Channel" button.
Desired behavior:
The load may run without a user id. If a user id is present when that load ends, or appears later, the chat runs the skipped write steps once. It creates the channel row if it is missing. It joins only if the user is still not a member. It then updates isUserChannelMember and the user's member row in the store. The feed and the composer stay as they are, with no return to loading. A short "Join Channel" may show until the writes end. Visitors still skip both writes. The fix stays inside the chat. It does not change the auth state handler and does not reload the page.
Key interfaces:
useChannelMetadata — the load effect and its user id gate.
upsertChannel() and joinChannel() — the two skipped write steps.
bootstrapChannel() — writes isUserChannelMember and the member row.
useJoinChannel — the manual join. It sets the member flag through setWorkspaceChannelSetting.
AccessControl in ChannelComposer — chooses the skeleton, the composer, or "Join Channel".
isChannelDataLoaded and isFeedReady — the loading gate for the feed and the composer.
joinedWorkspace — the workspace setting that turns true once a workspace join ends. It never resets to false.
Out of scope
Notes
Do not make the fix only a new effect dep on the profile id. The effect clears isChannelDataLoaded at its start (apps/webapp/src/components/chatroom/hooks/useChannelMetadata.ts:21). isFeedReady needs it (apps/webapp/src/components/chatroom/ChatroomContext.tsx:288), so the feed and the composer would go back to their skeletons.
The workspaceId dep does not cover this (apps/webapp/src/components/chatroom/hooks/useChannelMetadata.ts:15, :76). It changes only with the document metadata, and an in-place sign-in does not change that.
Order the new writes after the workspace join. The join policy needs workspace membership (packages/supabase/scripts/13-RLS.sql:170-181). The hook's own comment says the channel create step is gated the same way (apps/webapp/src/components/chatroom/hooks/useChannelMetadata.ts:26-28). For a first-time visitor, join_workspace writes that membership. useJoinWorkspace starts that call only once a profile exists and the channel list has loaded, and sets joinedWorkspace when it ends (apps/webapp/src/hooks/useJoinWorkspace.ts:18-24). Nothing sets joinedWorkspace back to false (apps/webapp/src/stores/workspace.ts:64, apps/webapp/src/hooks/useJoinWorkspace.ts:24). After a sign-out and a document switch in the same tab, it can be true while the new join still runs. Do not use it alone as the gate.
The skipped channel create step causes a second failure. A heading chat with no channel row gets an empty result (packages/supabase/scripts/10-functions.sql:94-113). AccessControl then shows the composer (apps/webapp/src/components/chatroom/components/ChannelComposer/ChannelComposer.tsx:39). A send then fails. The message insert policy needs internal.can_read_channel(channel_id), and that check needs the channel row (packages/supabase/scripts/13-RLS.sql:211-216, packages/supabase/scripts/10-0-func-helpers.sql:143-162).
Do not reuse the missing-row check (apps/webapp/src/components/chatroom/hooks/useChannelMetadata.ts:29-31). It reads workspaceSettings.channels, and the first load writes an entry there even when the row is missing (apps/webapp/src/stores/chat/bootstrapStore.ts:24-27). Use channel_info from the first load. A null value means the row is missing.
The effect also passes no user id to bootstrapChannel (apps/webapp/src/components/chatroom/hooks/useChannelMetadata.ts:66). So the user's member row is not stored (apps/webapp/src/stores/chat/bootstrapStore.ts:29-36). The read cursor seed then finds no row (apps/webapp/src/components/chatroom/ChatroomContext.tsx:158-164). See the unread rule at apps/webapp/src/components/chatroom/CLAUDE.md:17. This part was not traced to a visible symptom.
Keep the chatroom rules. Writes stay gated on the user id at the entry point (apps/webapp/src/components/chatroom/CLAUDE.md:117). Sign-in call sites write their own state, and a guarded SIGNED_IN handler was rejected (:119).
joinChannel returns a policy error instead of throwing it (apps/webapp/src/api/channels/joinChannel.ts:15-17). So the hook marks a failed join as a member (apps/webapp/src/components/chatroom/hooks/useChannelMetadata.ts:59-61). Do not rely on its catch to detect a failed join.
Coordinate with #283. Both change what the composer area shows.
The evidence is a code trace on 14ab7f9c1, confirmed by two verifiers. It was not reproduced in a browser.
Problem
A signed-in user can see "Join Channel" in place of the composer. This happens when the profile loads after the heading chat opened. The chat runs its join step only once, when it opens.
On
14ab7f9c1. Paths are underapps/webapp/src/.useChannelMetadatareads the user id once, inside its load effect (components/chatroom/hooks/useChannelMetadata.ts:24). The effect deps are the channel id and the workspace id (:76). The provider key is the heading id (components/chatroom/Chatroom.tsx:38). So nothing runs the effect again when the profile loads.:31) and the join step (:58).bootstrapChannelthen stores the member flagisUserChannelMemberfrom the server value, which is false (stores/chat/bootstrapStore.ts:25).AccessControlshows the composer (components/chatroom/components/ChannelComposer/ChannelComposer.tsx:39). When the profile loads, a PUBLIC chat with the member flag false shows "Join Channel" instead (:55).components/chatroom/hooks/useJoinChannel.ts:19). For an account that is not yet a member, one click on "Join Channel" brings the composer back. For an account that is already a member, the click fails, and the composer comes back only when the chat opens again. The user should not need that click.Two paths open the chat before the profile loads:
components/pages/document/DocumentPage.tsx:56). It sets the profile atcomponents/GoogleOneTapAuth.tsx:54, throughcomponents/auth/applySignedInProfile.ts:28. The pad provider is keyed on the document id only (hooks/useYdocAndProvider.ts:250), so the open chat stays mounted.hooks/useOnAuthStateChange.ts:69-71). A chat deep link waits for the document, not the profile (hooks/useCheckUrlAndOpenHeadingChat.ts:41). This is a race. It needs a profile fetch that is slower than the document sync plus the 800 ms delay (hooks/useCheckUrlAndOpenHeadingChat.ts:53-59). Here the session is present, so only an account that is not yet a member sees "Join Channel".In the One Tap path, even an account that is already a member of that chat sees "Join Channel". The first load ran as a visitor, so the server returned false.
Steps to reproduce
?chatroom=<heading id>, for a heading chat that has messages. The link opens the chat without a click, so the One Tap prompt stays open.Acceptance criteria
Agent Brief
Category: bug
Summary: When the profile loads after a chat opens, the chat must still run its skipped write steps, so a signed-in user gets the composer.
Current behavior:
useChannelMetadatareads the user id once, inside its load effect. The effect does not run again when the profile loads. With no user id, it skipsupsertChannelandjoinChannel.bootstrapChannelstoresisUserChannelMemberas false. When the profile loads,AccessControlswaps the composer for the "Join Channel" button.Desired behavior:
The load may run without a user id. If a user id is present when that load ends, or appears later, the chat runs the skipped write steps once. It creates the channel row if it is missing. It joins only if the user is still not a member. It then updates
isUserChannelMemberand the user's member row in the store. The feed and the composer stay as they are, with no return to loading. A short "Join Channel" may show until the writes end. Visitors still skip both writes. The fix stays inside the chat. It does not change the auth state handler and does not reload the page.Key interfaces:
useChannelMetadata— the load effect and its user id gate.upsertChannel()andjoinChannel()— the two skipped write steps.bootstrapChannel()— writesisUserChannelMemberand the member row.useJoinChannel— the manual join. It sets the member flag throughsetWorkspaceChannelSetting.AccessControlinChannelComposer— chooses the skeleton, the composer, or "Join Channel".isChannelDataLoadedandisFeedReady— the loading gate for the feed and the composer.joinedWorkspace— the workspace setting that turns true once a workspace join ends. It never resets to false.Out of scope
joinChannelreturning a policy error instead of throwing it. That is a separate defect.Notes
Do not make the fix only a new effect dep on the profile id. The effect clears
isChannelDataLoadedat its start (apps/webapp/src/components/chatroom/hooks/useChannelMetadata.ts:21).isFeedReadyneeds it (apps/webapp/src/components/chatroom/ChatroomContext.tsx:288), so the feed and the composer would go back to their skeletons.The
workspaceIddep does not cover this (apps/webapp/src/components/chatroom/hooks/useChannelMetadata.ts:15,:76). It changes only with the document metadata, and an in-place sign-in does not change that.Order the new writes after the workspace join. The join policy needs workspace membership (
packages/supabase/scripts/13-RLS.sql:170-181). The hook's own comment says the channel create step is gated the same way (apps/webapp/src/components/chatroom/hooks/useChannelMetadata.ts:26-28). For a first-time visitor,join_workspacewrites that membership.useJoinWorkspacestarts that call only once a profile exists and the channel list has loaded, and setsjoinedWorkspacewhen it ends (apps/webapp/src/hooks/useJoinWorkspace.ts:18-24). Nothing setsjoinedWorkspaceback to false (apps/webapp/src/stores/workspace.ts:64,apps/webapp/src/hooks/useJoinWorkspace.ts:24). After a sign-out and a document switch in the same tab, it can be true while the new join still runs. Do not use it alone as the gate.The skipped channel create step causes a second failure. A heading chat with no channel row gets an empty result (
packages/supabase/scripts/10-functions.sql:94-113).AccessControlthen shows the composer (apps/webapp/src/components/chatroom/components/ChannelComposer/ChannelComposer.tsx:39). A send then fails. The message insert policy needsinternal.can_read_channel(channel_id), and that check needs the channel row (packages/supabase/scripts/13-RLS.sql:211-216,packages/supabase/scripts/10-0-func-helpers.sql:143-162).Do not reuse the missing-row check (
apps/webapp/src/components/chatroom/hooks/useChannelMetadata.ts:29-31). It readsworkspaceSettings.channels, and the first load writes an entry there even when the row is missing (apps/webapp/src/stores/chat/bootstrapStore.ts:24-27). Usechannel_infofrom the first load. A null value means the row is missing.The effect also passes no user id to
bootstrapChannel(apps/webapp/src/components/chatroom/hooks/useChannelMetadata.ts:66). So the user's member row is not stored (apps/webapp/src/stores/chat/bootstrapStore.ts:29-36). The read cursor seed then finds no row (apps/webapp/src/components/chatroom/ChatroomContext.tsx:158-164). See the unread rule atapps/webapp/src/components/chatroom/CLAUDE.md:17. This part was not traced to a visible symptom.Keep the chatroom rules. Writes stay gated on the user id at the entry point (
apps/webapp/src/components/chatroom/CLAUDE.md:117). Sign-in call sites write their own state, and a guardedSIGNED_INhandler was rejected (:119).joinChannelreturns a policy error instead of throwing it (apps/webapp/src/api/channels/joinChannel.ts:15-17). So the hook marks a failed join as a member (apps/webapp/src/components/chatroom/hooks/useChannelMetadata.ts:59-61). Do not rely on itscatchto detect a failed join.Coordinate with #283. Both change what the composer area shows.
The evidence is a code trace on
14ab7f9c1, confirmed by two verifiers. It was not reproduced in a browser.