Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9e77008
test(ui): cover the connected UserButton end to end
alexcarpenter Aug 3, 2026
f081eb3
feat(ui): close the UserButton popover only when a workspace is picked
alexcarpenter Aug 3, 2026
9ff3048
test(ui): query the account menu trigger as a button
alexcarpenter Aug 3, 2026
73e44b9
refactor(ui): migrate the Mosaic Spinner to StyleX and give it a sm size
alexcarpenter Aug 3, 2026
d997d2a
feat(ui): name the active workspace in the UserButton trigger
alexcarpenter Aug 3, 2026
400451f
feat(ui): let combined UserButton lead with the organization or the a…
alexcarpenter Aug 3, 2026
f15f6f6
test(ui): follow the UserButton active-organization contract in the c…
alexcarpenter Aug 4, 2026
6de655d
test(ui): carry organizationMemberships on the connected UserButton u…
alexcarpenter Aug 4, 2026
60d2261
fix(ui): hold the Mosaic UserButton surface still while an action runs
alexcarpenter Aug 4, 2026
b4af3a4
test(ui): cover the UserButton profile modals end to end
alexcarpenter Aug 5, 2026
6751b12
feat(ui): close the UserButton popover behind whatever it opens
alexcarpenter Aug 5, 2026
09139d7
test(ui): cover the UserButton personal workspace row end to end
alexcarpenter Aug 5, 2026
4ad031b
test(ui): cover the named UserButton personal row end to end
alexcarpenter Aug 5, 2026
6a1842b
test(ui): move UserButton rendering cases down to the view test
alexcarpenter Aug 4, 2026
8822ce5
feat(ui): forward mode to the connected UserButton
alexcarpenter Aug 4, 2026
a0c4c8b
test(ui): cover the UserButton create-organization modal end to end
alexcarpenter Aug 5, 2026
df762fb
test(ui): assert Invite opens the InviteMembers modal
alexcarpenter Aug 5, 2026
9a3ba9a
test(ui): identify Alice's account row by her username
alexcarpenter Aug 5, 2026
dd0893c
test(ui): follow the join button's in-place spinner
alexcarpenter Aug 6, 2026
0716089
test(ui): cover a custom menu item end to end
alexcarpenter Aug 6, 2026
abc6347
fix(ui): make UserButton account-only where organizations are disabled
alexcarpenter Aug 6, 2026
c132c7b
refactor(ui): drive the Mosaic UserButton popover from a state machine
alexcarpenter Aug 7, 2026
fb0a499
refactor(ui): drop comments the integration assertions already carry
alexcarpenter Aug 7, 2026
e48835a
refactor(ui): name the organization mode in full on the connected button
alexcarpenter Aug 7, 2026
3bfa70d
feat(ui): add custom UserProfile pages to the Mosaic UserButton
alexcarpenter Aug 7, 2026
5565f1c
chore(repo): consolidate the branch's changesets into one
alexcarpenter Aug 7, 2026
977b36a
refactor(ui): colocate the user button's page hooks with the button
alexcarpenter Aug 10, 2026
ea61af1
fix(ui): drop the Mosaic UserButton spinner hold when the action clos…
alexcarpenter Aug 10, 2026
83a3958
test(ui): follow the user button rows' aria-disabled through the inte…
alexcarpenter Aug 10, 2026
30adecc
refactor(ui): name the Mosaic UserButton layers for their MVC roles v…
Ephem Aug 24, 2026
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-integration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,16 @@ import { useClerk } from './useClerk';
*
* @internal
*/
export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'useOrganizationList') {
export function useAttemptToEnableOrganizations(
caller: 'useOrganization' | 'useOrganizationList',
{ enabled = true }: { enabled?: boolean } = {},
) {
const clerk = useClerk();
const hasAttempted = useRef(false);

useEffect(() => {
// Guard to not run this effect twice on Clerk resource update
if (hasAttempted.current) {
if (!enabled || hasAttempted.current) {
return;
}

Expand All@@ -23,5 +26,5 @@ export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'use
for: 'organizations',
caller,
});
}, [clerk, caller]);
}, [clerk, caller, enabled]);
}
11 changes: 10 additions & 1 deletion packages/shared/src/react/hooks/useOrganization.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,12 @@ export type UseOrganizationParams = {
* </ul>
*/
invitations?: true | PaginatedHookConfig<GetInvitationsParams>;
/**
* Skip the development prompt that offers to enable Organizations.
*
* @internal
*/
__internal_skipAttemptToEnableOrganizations?: boolean;
};

/**
Expand DownExpand Up@@ -275,10 +281,13 @@ export function useOrganization<T extends UseOrganizationParams>(params?: T): Us
membershipRequests: membershipRequestsListParams,
memberships: membersListParams,
invitations: invitationsListParams,
__internal_skipAttemptToEnableOrganizations,
} = params || {};

useAssertWrappedByClerkProvider('useOrganization');
useAttemptToEnableOrganizations('useOrganization');
useAttemptToEnableOrganizations('useOrganization', {
enabled: !__internal_skipAttemptToEnableOrganizations,
});

const organization = useOrganizationBase();
const session = useSessionBase();
Expand Down
5 changes: 4 additions & 1 deletion packages/shared/src/react/hooks/useOrganizationList.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,7 +253,10 @@ export function useOrganizationList<T extends UseOrganizationListParams>(params?
const { userMemberships, userInvitations, userSuggestions } = params || {};

useAssertWrappedByClerkProvider('useOrganizationList');
useAttemptToEnableOrganizations('useOrganizationList');
// No list keys means this call is not using Organizations; the prompt is for the lists.
useAttemptToEnableOrganizations('useOrganizationList', {
enabled: userMemberships !== undefined || userInvitations !== undefined || userSuggestions !== undefined,
});

const userMembershipsSafeValues = useWithSafeValues(userMemberships, {
initialPage: 1,
Expand Down
10 changes: 7 additions & 3 deletions packages/ui/src/hooks/useOrganizationListInView.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,14 +5,18 @@ import { useInView } from './useInView';

/**
* @internal
*
* `enabled` withholds the list params so the three requests do not start. Defaults on.
*/
export const useOrganizationListInView = () => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(organizationListParams);
export const useOrganizationListInView = ({ enabled = true }: { enabled?: boolean } = {}) => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(
enabled ? organizationListParams : undefined,
);

const { ref } = useInView({
threshold: 0,
onChange: inView => {
if (!inView) {
if (!enabled || !inView) {
return;
}
if (userMemberships.hasNextPage) {
Expand Down
18 changes: 14 additions & 4 deletions packages/ui/src/mosaic/components/button/submit-button.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,19 +302,29 @@ describe('Mosaic SubmitButton spin delay', () => {
expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

// A consumer who already knows the action is slow has nothing to gain by waiting.
// A consumer who already knows the action is slow has nothing to gain by waiting: there is no
// delay left to outlast, so the spinner shows in the render that starts the action rather than a
// timer's.
it('lets the consumer opt out of the delay', () => {
render(
const { rerender } = render(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
spinDelay={{ delay: 1000 }}
>
Save
</SubmitButton>,
);
const hidden = atoms(spinner());

advance(0);
rerender(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
>
Save
</SubmitButton>,
);

expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

Expand Down
21 changes: 21 additions & 0 deletions packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,27 @@ describe('useSpinDelay', () => {
expect(result.current).toBeNull();
});

// Direct feedback on a click has nothing to debounce, so a zero delay must not cost a timer's
// worth of render passes before the spinner appears.
it('surfaces the value in the same pass when there is no delay to wait out', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));

expect(result.current).toBe('a');
});

it('still holds a zero-delay value for minDuration', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
await act(() => rerender({ value: null }));

await advance(199);
expect(result.current).toBe('a');

await advance(1);
expect(result.current).toBeNull();
});

it('swaps to a new value immediately when one replaces another mid-show', async () => {
const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
Expand Down
10 changes: 8 additions & 2 deletions packages/ui/src/mosaic/hooks/useSpinDelay.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';

export interface SpinDelayOptions {
/** Wait this long before showing the value, so quick actions never flash a spinner. */
/** Wait this long before showing the value, so quick actions never flash a spinner. `0` shows it straight away. */
delay?: number;
/** Once shown, keep the value up at least this long, so the spinner never flickers off. */
minDuration?: number;
Expand All@@ -25,11 +25,17 @@ export function useSpinDelay<T>(value: T | null, options: SpinDelayOptions = {})
const shownAt = useRef(0);

useEffect(() => {
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`.
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`. With no
// delay there is nothing to outlast, so it surfaces in this pass rather than a timer's.
if (shown === null) {
if (value === null) {
return;
}
if (delay <= 0) {
shownAt.current = Date.now();
setShown(value);
return;
}
const timer = setTimeout(() => {
shownAt.current = Date.now();
setShown(value);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9e77008
test(ui): cover the connected UserButton end to end
alexcarpenter Aug 3, 2026
f081eb3
feat(ui): close the UserButton popover only when a workspace is picked
alexcarpenter Aug 3, 2026
9ff3048
test(ui): query the account menu trigger as a button
alexcarpenter Aug 3, 2026
73e44b9
refactor(ui): migrate the Mosaic Spinner to StyleX and give it a sm size
alexcarpenter Aug 3, 2026
d997d2a
feat(ui): name the active workspace in the UserButton trigger
alexcarpenter Aug 3, 2026
400451f
feat(ui): let combined UserButton lead with the organization or the a…
alexcarpenter Aug 3, 2026
f15f6f6
test(ui): follow the UserButton active-organization contract in the c…
alexcarpenter Aug 4, 2026
6de655d
test(ui): carry organizationMemberships on the connected UserButton u…
alexcarpenter Aug 4, 2026
60d2261
fix(ui): hold the Mosaic UserButton surface still while an action runs
alexcarpenter Aug 4, 2026
b4af3a4
test(ui): cover the UserButton profile modals end to end
alexcarpenter Aug 5, 2026
6751b12
feat(ui): close the UserButton popover behind whatever it opens
alexcarpenter Aug 5, 2026
09139d7
test(ui): cover the UserButton personal workspace row end to end
alexcarpenter Aug 5, 2026
4ad031b
test(ui): cover the named UserButton personal row end to end
alexcarpenter Aug 5, 2026
6a1842b
test(ui): move UserButton rendering cases down to the view test
alexcarpenter Aug 4, 2026
8822ce5
feat(ui): forward mode to the connected UserButton
alexcarpenter Aug 4, 2026
a0c4c8b
test(ui): cover the UserButton create-organization modal end to end
alexcarpenter Aug 5, 2026
df762fb
test(ui): assert Invite opens the InviteMembers modal
alexcarpenter Aug 5, 2026
9a3ba9a
test(ui): identify Alice's account row by her username
alexcarpenter Aug 5, 2026
dd0893c
test(ui): follow the join button's in-place spinner
alexcarpenter Aug 6, 2026
0716089
test(ui): cover a custom menu item end to end
alexcarpenter Aug 6, 2026
abc6347
fix(ui): make UserButton account-only where organizations are disabled
alexcarpenter Aug 6, 2026
c132c7b
refactor(ui): drive the Mosaic UserButton popover from a state machine
alexcarpenter Aug 7, 2026
fb0a499
refactor(ui): drop comments the integration assertions already carry
alexcarpenter Aug 7, 2026
e48835a
refactor(ui): name the organization mode in full on the connected button
alexcarpenter Aug 7, 2026
3bfa70d
feat(ui): add custom UserProfile pages to the Mosaic UserButton
alexcarpenter Aug 7, 2026
5565f1c
chore(repo): consolidate the branch's changesets into one
alexcarpenter Aug 7, 2026
977b36a
refactor(ui): colocate the user button's page hooks with the button
alexcarpenter Aug 10, 2026
ea61af1
fix(ui): drop the Mosaic UserButton spinner hold when the action clos…
alexcarpenter Aug 10, 2026
83a3958
test(ui): follow the user button rows' aria-disabled through the inte…
alexcarpenter Aug 10, 2026
30adecc
refactor(ui): name the Mosaic UserButton layers for their MVC roles v…
Ephem Aug 24, 2026
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-integration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,16 @@ import { useClerk } from './useClerk';
*
* @internal
*/
export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'useOrganizationList') {
export function useAttemptToEnableOrganizations(
caller: 'useOrganization' | 'useOrganizationList',
{ enabled = true }: { enabled?: boolean } = {},
) {
const clerk = useClerk();
const hasAttempted = useRef(false);

useEffect(() => {
// Guard to not run this effect twice on Clerk resource update
if (hasAttempted.current) {
if (!enabled || hasAttempted.current) {
return;
}

Expand All@@ -23,5 +26,5 @@ export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'use
for: 'organizations',
caller,
});
}, [clerk, caller]);
}, [clerk, caller, enabled]);
}
11 changes: 10 additions & 1 deletion packages/shared/src/react/hooks/useOrganization.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,12 @@ export type UseOrganizationParams = {
* </ul>
*/
invitations?: true | PaginatedHookConfig<GetInvitationsParams>;
/**
* Skip the development prompt that offers to enable Organizations.
*
* @internal
*/
__internal_skipAttemptToEnableOrganizations?: boolean;
};

/**
Expand DownExpand Up@@ -275,10 +281,13 @@ export function useOrganization<T extends UseOrganizationParams>(params?: T): Us
membershipRequests: membershipRequestsListParams,
memberships: membersListParams,
invitations: invitationsListParams,
__internal_skipAttemptToEnableOrganizations,
} = params || {};

useAssertWrappedByClerkProvider('useOrganization');
useAttemptToEnableOrganizations('useOrganization');
useAttemptToEnableOrganizations('useOrganization', {
enabled: !__internal_skipAttemptToEnableOrganizations,
});

const organization = useOrganizationBase();
const session = useSessionBase();
Expand Down
5 changes: 4 additions & 1 deletion packages/shared/src/react/hooks/useOrganizationList.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,7 +253,10 @@ export function useOrganizationList<T extends UseOrganizationListParams>(params?
const { userMemberships, userInvitations, userSuggestions } = params || {};

useAssertWrappedByClerkProvider('useOrganizationList');
useAttemptToEnableOrganizations('useOrganizationList');
// No list keys means this call is not using Organizations; the prompt is for the lists.
useAttemptToEnableOrganizations('useOrganizationList', {
enabled: userMemberships !== undefined || userInvitations !== undefined || userSuggestions !== undefined,
});

const userMembershipsSafeValues = useWithSafeValues(userMemberships, {
initialPage: 1,
Expand Down
10 changes: 7 additions & 3 deletions packages/ui/src/hooks/useOrganizationListInView.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,14 +5,18 @@ import { useInView } from './useInView';

/**
* @internal
*
* `enabled` withholds the list params so the three requests do not start. Defaults on.
*/
export const useOrganizationListInView = () => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(organizationListParams);
export const useOrganizationListInView = ({ enabled = true }: { enabled?: boolean } = {}) => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(
enabled ? organizationListParams : undefined,
);

const { ref } = useInView({
threshold: 0,
onChange: inView => {
if (!inView) {
if (!enabled || !inView) {
return;
}
if (userMemberships.hasNextPage) {
Expand Down
18 changes: 14 additions & 4 deletions packages/ui/src/mosaic/components/button/submit-button.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,19 +302,29 @@ describe('Mosaic SubmitButton spin delay', () => {
expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

// A consumer who already knows the action is slow has nothing to gain by waiting.
// A consumer who already knows the action is slow has nothing to gain by waiting: there is no
// delay left to outlast, so the spinner shows in the render that starts the action rather than a
// timer's.
it('lets the consumer opt out of the delay', () => {
render(
const { rerender } = render(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
spinDelay={{ delay: 1000 }}
>
Save
</SubmitButton>,
);
const hidden = atoms(spinner());

advance(0);
rerender(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
>
Save
</SubmitButton>,
);

expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

Expand Down
21 changes: 21 additions & 0 deletions packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,27 @@ describe('useSpinDelay', () => {
expect(result.current).toBeNull();
});

// Direct feedback on a click has nothing to debounce, so a zero delay must not cost a timer's
// worth of render passes before the spinner appears.
it('surfaces the value in the same pass when there is no delay to wait out', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));

expect(result.current).toBe('a');
});

it('still holds a zero-delay value for minDuration', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
await act(() => rerender({ value: null }));

await advance(199);
expect(result.current).toBe('a');

await advance(1);
expect(result.current).toBeNull();
});

it('swaps to a new value immediately when one replaces another mid-show', async () => {
const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
Expand Down
10 changes: 8 additions & 2 deletions packages/ui/src/mosaic/hooks/useSpinDelay.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';

export interface SpinDelayOptions {
/** Wait this long before showing the value, so quick actions never flash a spinner. */
/** Wait this long before showing the value, so quick actions never flash a spinner. `0` shows it straight away. */
delay?: number;
/** Once shown, keep the value up at least this long, so the spinner never flickers off. */
minDuration?: number;
Expand All@@ -25,11 +25,17 @@ export function useSpinDelay<T>(value: T | null, options: SpinDelayOptions = {})
const shownAt = useRef(0);

useEffect(() => {
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`.
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`. With no
// delay there is nothing to outlast, so it surfaces in this pass rather than a timer's.
if (shown === null) {
if (value === null) {
return;
}
if (delay <= 0) {
shownAt.current = Date.now();
setShown(value);
return;
}
const timer = setTimeout(() => {
shownAt.current = Date.now();
setShown(value);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9e77008
test(ui): cover the connected UserButton end to end
alexcarpenter Aug 3, 2026
f081eb3
feat(ui): close the UserButton popover only when a workspace is picked
alexcarpenter Aug 3, 2026
9ff3048
test(ui): query the account menu trigger as a button
alexcarpenter Aug 3, 2026
73e44b9
refactor(ui): migrate the Mosaic Spinner to StyleX and give it a sm size
alexcarpenter Aug 3, 2026
d997d2a
feat(ui): name the active workspace in the UserButton trigger
alexcarpenter Aug 3, 2026
400451f
feat(ui): let combined UserButton lead with the organization or the a…
alexcarpenter Aug 3, 2026
f15f6f6
test(ui): follow the UserButton active-organization contract in the c…
alexcarpenter Aug 4, 2026
6de655d
test(ui): carry organizationMemberships on the connected UserButton u…
alexcarpenter Aug 4, 2026
60d2261
fix(ui): hold the Mosaic UserButton surface still while an action runs
alexcarpenter Aug 4, 2026
b4af3a4
test(ui): cover the UserButton profile modals end to end
alexcarpenter Aug 5, 2026
6751b12
feat(ui): close the UserButton popover behind whatever it opens
alexcarpenter Aug 5, 2026
09139d7
test(ui): cover the UserButton personal workspace row end to end
alexcarpenter Aug 5, 2026
4ad031b
test(ui): cover the named UserButton personal row end to end
alexcarpenter Aug 5, 2026
6a1842b
test(ui): move UserButton rendering cases down to the view test
alexcarpenter Aug 4, 2026
8822ce5
feat(ui): forward mode to the connected UserButton
alexcarpenter Aug 4, 2026
a0c4c8b
test(ui): cover the UserButton create-organization modal end to end
alexcarpenter Aug 5, 2026
df762fb
test(ui): assert Invite opens the InviteMembers modal
alexcarpenter Aug 5, 2026
9a3ba9a
test(ui): identify Alice's account row by her username
alexcarpenter Aug 5, 2026
dd0893c
test(ui): follow the join button's in-place spinner
alexcarpenter Aug 6, 2026
0716089
test(ui): cover a custom menu item end to end
alexcarpenter Aug 6, 2026
abc6347
fix(ui): make UserButton account-only where organizations are disabled
alexcarpenter Aug 6, 2026
c132c7b
refactor(ui): drive the Mosaic UserButton popover from a state machine
alexcarpenter Aug 7, 2026
fb0a499
refactor(ui): drop comments the integration assertions already carry
alexcarpenter Aug 7, 2026
e48835a
refactor(ui): name the organization mode in full on the connected button
alexcarpenter Aug 7, 2026
3bfa70d
feat(ui): add custom UserProfile pages to the Mosaic UserButton
alexcarpenter Aug 7, 2026
5565f1c
chore(repo): consolidate the branch's changesets into one
alexcarpenter Aug 7, 2026
977b36a
refactor(ui): colocate the user button's page hooks with the button
alexcarpenter Aug 10, 2026
ea61af1
fix(ui): drop the Mosaic UserButton spinner hold when the action clos…
alexcarpenter Aug 10, 2026
83a3958
test(ui): follow the user button rows' aria-disabled through the inte…
alexcarpenter Aug 10, 2026
30adecc
refactor(ui): name the Mosaic UserButton layers for their MVC roles v…
Ephem Aug 24, 2026
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-integration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,16 @@ import { useClerk } from './useClerk';
*
* @internal
*/
export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'useOrganizationList') {
export function useAttemptToEnableOrganizations(
caller: 'useOrganization' | 'useOrganizationList',
{ enabled = true }: { enabled?: boolean } = {},
) {
const clerk = useClerk();
const hasAttempted = useRef(false);

useEffect(() => {
// Guard to not run this effect twice on Clerk resource update
if (hasAttempted.current) {
if (!enabled || hasAttempted.current) {
return;
}

Expand All@@ -23,5 +26,5 @@ export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'use
for: 'organizations',
caller,
});
}, [clerk, caller]);
}, [clerk, caller, enabled]);
}
11 changes: 10 additions & 1 deletion packages/shared/src/react/hooks/useOrganization.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,12 @@ export type UseOrganizationParams = {
* </ul>
*/
invitations?: true | PaginatedHookConfig<GetInvitationsParams>;
/**
* Skip the development prompt that offers to enable Organizations.
*
* @internal
*/
__internal_skipAttemptToEnableOrganizations?: boolean;
};

/**
Expand DownExpand Up@@ -275,10 +281,13 @@ export function useOrganization<T extends UseOrganizationParams>(params?: T): Us
membershipRequests: membershipRequestsListParams,
memberships: membersListParams,
invitations: invitationsListParams,
__internal_skipAttemptToEnableOrganizations,
} = params || {};

useAssertWrappedByClerkProvider('useOrganization');
useAttemptToEnableOrganizations('useOrganization');
useAttemptToEnableOrganizations('useOrganization', {
enabled: !__internal_skipAttemptToEnableOrganizations,
});

const organization = useOrganizationBase();
const session = useSessionBase();
Expand Down
5 changes: 4 additions & 1 deletion packages/shared/src/react/hooks/useOrganizationList.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,7 +253,10 @@ export function useOrganizationList<T extends UseOrganizationListParams>(params?
const { userMemberships, userInvitations, userSuggestions } = params || {};

useAssertWrappedByClerkProvider('useOrganizationList');
useAttemptToEnableOrganizations('useOrganizationList');
// No list keys means this call is not using Organizations; the prompt is for the lists.
useAttemptToEnableOrganizations('useOrganizationList', {
enabled: userMemberships !== undefined || userInvitations !== undefined || userSuggestions !== undefined,
});

const userMembershipsSafeValues = useWithSafeValues(userMemberships, {
initialPage: 1,
Expand Down
10 changes: 7 additions & 3 deletions packages/ui/src/hooks/useOrganizationListInView.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,14 +5,18 @@ import { useInView } from './useInView';

/**
* @internal
*
* `enabled` withholds the list params so the three requests do not start. Defaults on.
*/
export const useOrganizationListInView = () => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(organizationListParams);
export const useOrganizationListInView = ({ enabled = true }: { enabled?: boolean } = {}) => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(
enabled ? organizationListParams : undefined,
);

const { ref } = useInView({
threshold: 0,
onChange: inView => {
if (!inView) {
if (!enabled || !inView) {
return;
}
if (userMemberships.hasNextPage) {
Expand Down
18 changes: 14 additions & 4 deletions packages/ui/src/mosaic/components/button/submit-button.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,19 +302,29 @@ describe('Mosaic SubmitButton spin delay', () => {
expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

// A consumer who already knows the action is slow has nothing to gain by waiting.
// A consumer who already knows the action is slow has nothing to gain by waiting: there is no
// delay left to outlast, so the spinner shows in the render that starts the action rather than a
// timer's.
it('lets the consumer opt out of the delay', () => {
render(
const { rerender } = render(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
spinDelay={{ delay: 1000 }}
>
Save
</SubmitButton>,
);
const hidden = atoms(spinner());

advance(0);
rerender(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
>
Save
</SubmitButton>,
);

expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

Expand Down
21 changes: 21 additions & 0 deletions packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,27 @@ describe('useSpinDelay', () => {
expect(result.current).toBeNull();
});

// Direct feedback on a click has nothing to debounce, so a zero delay must not cost a timer's
// worth of render passes before the spinner appears.
it('surfaces the value in the same pass when there is no delay to wait out', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));

expect(result.current).toBe('a');
});

it('still holds a zero-delay value for minDuration', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
await act(() => rerender({ value: null }));

await advance(199);
expect(result.current).toBe('a');

await advance(1);
expect(result.current).toBeNull();
});

it('swaps to a new value immediately when one replaces another mid-show', async () => {
const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
Expand Down
10 changes: 8 additions & 2 deletions packages/ui/src/mosaic/hooks/useSpinDelay.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';

export interface SpinDelayOptions {
/** Wait this long before showing the value, so quick actions never flash a spinner. */
/** Wait this long before showing the value, so quick actions never flash a spinner. `0` shows it straight away. */
delay?: number;
/** Once shown, keep the value up at least this long, so the spinner never flickers off. */
minDuration?: number;
Expand All@@ -25,11 +25,17 @@ export function useSpinDelay<T>(value: T | null, options: SpinDelayOptions = {})
const shownAt = useRef(0);

useEffect(() => {
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`.
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`. With no
// delay there is nothing to outlast, so it surfaces in this pass rather than a timer's.
if (shown === null) {
if (value === null) {
return;
}
if (delay <= 0) {
shownAt.current = Date.now();
setShown(value);
return;
}
const timer = setTimeout(() => {
shownAt.current = Date.now();
setShown(value);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9e77008
test(ui): cover the connected UserButton end to end
alexcarpenter Aug 3, 2026
f081eb3
feat(ui): close the UserButton popover only when a workspace is picked
alexcarpenter Aug 3, 2026
9ff3048
test(ui): query the account menu trigger as a button
alexcarpenter Aug 3, 2026
73e44b9
refactor(ui): migrate the Mosaic Spinner to StyleX and give it a sm size
alexcarpenter Aug 3, 2026
d997d2a
feat(ui): name the active workspace in the UserButton trigger
alexcarpenter Aug 3, 2026
400451f
feat(ui): let combined UserButton lead with the organization or the a…
alexcarpenter Aug 3, 2026
f15f6f6
test(ui): follow the UserButton active-organization contract in the c…
alexcarpenter Aug 4, 2026
6de655d
test(ui): carry organizationMemberships on the connected UserButton u…
alexcarpenter Aug 4, 2026
60d2261
fix(ui): hold the Mosaic UserButton surface still while an action runs
alexcarpenter Aug 4, 2026
b4af3a4
test(ui): cover the UserButton profile modals end to end
alexcarpenter Aug 5, 2026
6751b12
feat(ui): close the UserButton popover behind whatever it opens
alexcarpenter Aug 5, 2026
09139d7
test(ui): cover the UserButton personal workspace row end to end
alexcarpenter Aug 5, 2026
4ad031b
test(ui): cover the named UserButton personal row end to end
alexcarpenter Aug 5, 2026
6a1842b
test(ui): move UserButton rendering cases down to the view test
alexcarpenter Aug 4, 2026
8822ce5
feat(ui): forward mode to the connected UserButton
alexcarpenter Aug 4, 2026
a0c4c8b
test(ui): cover the UserButton create-organization modal end to end
alexcarpenter Aug 5, 2026
df762fb
test(ui): assert Invite opens the InviteMembers modal
alexcarpenter Aug 5, 2026
9a3ba9a
test(ui): identify Alice's account row by her username
alexcarpenter Aug 5, 2026
dd0893c
test(ui): follow the join button's in-place spinner
alexcarpenter Aug 6, 2026
0716089
test(ui): cover a custom menu item end to end
alexcarpenter Aug 6, 2026
abc6347
fix(ui): make UserButton account-only where organizations are disabled
alexcarpenter Aug 6, 2026
c132c7b
refactor(ui): drive the Mosaic UserButton popover from a state machine
alexcarpenter Aug 7, 2026
fb0a499
refactor(ui): drop comments the integration assertions already carry
alexcarpenter Aug 7, 2026
e48835a
refactor(ui): name the organization mode in full on the connected button
alexcarpenter Aug 7, 2026
3bfa70d
feat(ui): add custom UserProfile pages to the Mosaic UserButton
alexcarpenter Aug 7, 2026
5565f1c
chore(repo): consolidate the branch's changesets into one
alexcarpenter Aug 7, 2026
977b36a
refactor(ui): colocate the user button's page hooks with the button
alexcarpenter Aug 10, 2026
ea61af1
fix(ui): drop the Mosaic UserButton spinner hold when the action clos…
alexcarpenter Aug 10, 2026
83a3958
test(ui): follow the user button rows' aria-disabled through the inte…
alexcarpenter Aug 10, 2026
30adecc
refactor(ui): name the Mosaic UserButton layers for their MVC roles v…
Ephem Aug 24, 2026
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-integration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,16 @@ import { useClerk } from './useClerk';
*
* @internal
*/
export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'useOrganizationList') {
export function useAttemptToEnableOrganizations(
caller: 'useOrganization' | 'useOrganizationList',
{ enabled = true }: { enabled?: boolean } = {},
) {
const clerk = useClerk();
const hasAttempted = useRef(false);

useEffect(() => {
// Guard to not run this effect twice on Clerk resource update
if (hasAttempted.current) {
if (!enabled || hasAttempted.current) {
return;
}

Expand All@@ -23,5 +26,5 @@ export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'use
for: 'organizations',
caller,
});
}, [clerk, caller]);
}, [clerk, caller, enabled]);
}
11 changes: 10 additions & 1 deletion packages/shared/src/react/hooks/useOrganization.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,12 @@ export type UseOrganizationParams = {
* </ul>
*/
invitations?: true | PaginatedHookConfig<GetInvitationsParams>;
/**
* Skip the development prompt that offers to enable Organizations.
*
* @internal
*/
__internal_skipAttemptToEnableOrganizations?: boolean;
};

/**
Expand DownExpand Up@@ -275,10 +281,13 @@ export function useOrganization<T extends UseOrganizationParams>(params?: T): Us
membershipRequests: membershipRequestsListParams,
memberships: membersListParams,
invitations: invitationsListParams,
__internal_skipAttemptToEnableOrganizations,
} = params || {};

useAssertWrappedByClerkProvider('useOrganization');
useAttemptToEnableOrganizations('useOrganization');
useAttemptToEnableOrganizations('useOrganization', {
enabled: !__internal_skipAttemptToEnableOrganizations,
});

const organization = useOrganizationBase();
const session = useSessionBase();
Expand Down
5 changes: 4 additions & 1 deletion packages/shared/src/react/hooks/useOrganizationList.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,7 +253,10 @@ export function useOrganizationList<T extends UseOrganizationListParams>(params?
const { userMemberships, userInvitations, userSuggestions } = params || {};

useAssertWrappedByClerkProvider('useOrganizationList');
useAttemptToEnableOrganizations('useOrganizationList');
// No list keys means this call is not using Organizations; the prompt is for the lists.
useAttemptToEnableOrganizations('useOrganizationList', {
enabled: userMemberships !== undefined || userInvitations !== undefined || userSuggestions !== undefined,
});

const userMembershipsSafeValues = useWithSafeValues(userMemberships, {
initialPage: 1,
Expand Down
10 changes: 7 additions & 3 deletions packages/ui/src/hooks/useOrganizationListInView.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,14 +5,18 @@ import { useInView } from './useInView';

/**
* @internal
*
* `enabled` withholds the list params so the three requests do not start. Defaults on.
*/
export const useOrganizationListInView = () => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(organizationListParams);
export const useOrganizationListInView = ({ enabled = true }: { enabled?: boolean } = {}) => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(
enabled ? organizationListParams : undefined,
);

const { ref } = useInView({
threshold: 0,
onChange: inView => {
if (!inView) {
if (!enabled || !inView) {
return;
}
if (userMemberships.hasNextPage) {
Expand Down
18 changes: 14 additions & 4 deletions packages/ui/src/mosaic/components/button/submit-button.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,19 +302,29 @@ describe('Mosaic SubmitButton spin delay', () => {
expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

// A consumer who already knows the action is slow has nothing to gain by waiting.
// A consumer who already knows the action is slow has nothing to gain by waiting: there is no
// delay left to outlast, so the spinner shows in the render that starts the action rather than a
// timer's.
it('lets the consumer opt out of the delay', () => {
render(
const { rerender } = render(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
spinDelay={{ delay: 1000 }}
>
Save
</SubmitButton>,
);
const hidden = atoms(spinner());

advance(0);
rerender(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
>
Save
</SubmitButton>,
);

expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

Expand Down
21 changes: 21 additions & 0 deletions packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,27 @@ describe('useSpinDelay', () => {
expect(result.current).toBeNull();
});

// Direct feedback on a click has nothing to debounce, so a zero delay must not cost a timer's
// worth of render passes before the spinner appears.
it('surfaces the value in the same pass when there is no delay to wait out', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));

expect(result.current).toBe('a');
});

it('still holds a zero-delay value for minDuration', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
await act(() => rerender({ value: null }));

await advance(199);
expect(result.current).toBe('a');

await advance(1);
expect(result.current).toBeNull();
});

it('swaps to a new value immediately when one replaces another mid-show', async () => {
const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
Expand Down
10 changes: 8 additions & 2 deletions packages/ui/src/mosaic/hooks/useSpinDelay.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';

export interface SpinDelayOptions {
/** Wait this long before showing the value, so quick actions never flash a spinner. */
/** Wait this long before showing the value, so quick actions never flash a spinner. `0` shows it straight away. */
delay?: number;
/** Once shown, keep the value up at least this long, so the spinner never flickers off. */
minDuration?: number;
Expand All@@ -25,11 +25,17 @@ export function useSpinDelay<T>(value: T | null, options: SpinDelayOptions = {})
const shownAt = useRef(0);

useEffect(() => {
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`.
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`. With no
// delay there is nothing to outlast, so it surfaces in this pass rather than a timer's.
if (shown === null) {
if (value === null) {
return;
}
if (delay <= 0) {
shownAt.current = Date.now();
setShown(value);
return;
}
const timer = setTimeout(() => {
shownAt.current = Date.now();
setShown(value);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9e77008
test(ui): cover the connected UserButton end to end
alexcarpenter Aug 3, 2026
f081eb3
feat(ui): close the UserButton popover only when a workspace is picked
alexcarpenter Aug 3, 2026
9ff3048
test(ui): query the account menu trigger as a button
alexcarpenter Aug 3, 2026
73e44b9
refactor(ui): migrate the Mosaic Spinner to StyleX and give it a sm size
alexcarpenter Aug 3, 2026
d997d2a
feat(ui): name the active workspace in the UserButton trigger
alexcarpenter Aug 3, 2026
400451f
feat(ui): let combined UserButton lead with the organization or the a…
alexcarpenter Aug 3, 2026
f15f6f6
test(ui): follow the UserButton active-organization contract in the c…
alexcarpenter Aug 4, 2026
6de655d
test(ui): carry organizationMemberships on the connected UserButton u…
alexcarpenter Aug 4, 2026
60d2261
fix(ui): hold the Mosaic UserButton surface still while an action runs
alexcarpenter Aug 4, 2026
b4af3a4
test(ui): cover the UserButton profile modals end to end
alexcarpenter Aug 5, 2026
6751b12
feat(ui): close the UserButton popover behind whatever it opens
alexcarpenter Aug 5, 2026
09139d7
test(ui): cover the UserButton personal workspace row end to end
alexcarpenter Aug 5, 2026
4ad031b
test(ui): cover the named UserButton personal row end to end
alexcarpenter Aug 5, 2026
6a1842b
test(ui): move UserButton rendering cases down to the view test
alexcarpenter Aug 4, 2026
8822ce5
feat(ui): forward mode to the connected UserButton
alexcarpenter Aug 4, 2026
a0c4c8b
test(ui): cover the UserButton create-organization modal end to end
alexcarpenter Aug 5, 2026
df762fb
test(ui): assert Invite opens the InviteMembers modal
alexcarpenter Aug 5, 2026
9a3ba9a
test(ui): identify Alice's account row by her username
alexcarpenter Aug 5, 2026
dd0893c
test(ui): follow the join button's in-place spinner
alexcarpenter Aug 6, 2026
0716089
test(ui): cover a custom menu item end to end
alexcarpenter Aug 6, 2026
abc6347
fix(ui): make UserButton account-only where organizations are disabled
alexcarpenter Aug 6, 2026
c132c7b
refactor(ui): drive the Mosaic UserButton popover from a state machine
alexcarpenter Aug 7, 2026
fb0a499
refactor(ui): drop comments the integration assertions already carry
alexcarpenter Aug 7, 2026
e48835a
refactor(ui): name the organization mode in full on the connected button
alexcarpenter Aug 7, 2026
3bfa70d
feat(ui): add custom UserProfile pages to the Mosaic UserButton
alexcarpenter Aug 7, 2026
5565f1c
chore(repo): consolidate the branch's changesets into one
alexcarpenter Aug 7, 2026
977b36a
refactor(ui): colocate the user button's page hooks with the button
alexcarpenter Aug 10, 2026
ea61af1
fix(ui): drop the Mosaic UserButton spinner hold when the action clos…
alexcarpenter Aug 10, 2026
83a3958
test(ui): follow the user button rows' aria-disabled through the inte…
alexcarpenter Aug 10, 2026
30adecc
refactor(ui): name the Mosaic UserButton layers for their MVC roles v…
Ephem Aug 24, 2026
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-integration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,16 @@ import { useClerk } from './useClerk';
*
* @internal
*/
export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'useOrganizationList') {
export function useAttemptToEnableOrganizations(
caller: 'useOrganization' | 'useOrganizationList',
{ enabled = true }: { enabled?: boolean } = {},
) {
const clerk = useClerk();
const hasAttempted = useRef(false);

useEffect(() => {
// Guard to not run this effect twice on Clerk resource update
if (hasAttempted.current) {
if (!enabled || hasAttempted.current) {
return;
}

Expand All@@ -23,5 +26,5 @@ export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'use
for: 'organizations',
caller,
});
}, [clerk, caller]);
}, [clerk, caller, enabled]);
}
11 changes: 10 additions & 1 deletion packages/shared/src/react/hooks/useOrganization.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,12 @@ export type UseOrganizationParams = {
* </ul>
*/
invitations?: true | PaginatedHookConfig<GetInvitationsParams>;
/**
* Skip the development prompt that offers to enable Organizations.
*
* @internal
*/
__internal_skipAttemptToEnableOrganizations?: boolean;
};

/**
Expand DownExpand Up@@ -275,10 +281,13 @@ export function useOrganization<T extends UseOrganizationParams>(params?: T): Us
membershipRequests: membershipRequestsListParams,
memberships: membersListParams,
invitations: invitationsListParams,
__internal_skipAttemptToEnableOrganizations,
} = params || {};

useAssertWrappedByClerkProvider('useOrganization');
useAttemptToEnableOrganizations('useOrganization');
useAttemptToEnableOrganizations('useOrganization', {
enabled: !__internal_skipAttemptToEnableOrganizations,
});

const organization = useOrganizationBase();
const session = useSessionBase();
Expand Down
5 changes: 4 additions & 1 deletion packages/shared/src/react/hooks/useOrganizationList.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,7 +253,10 @@ export function useOrganizationList<T extends UseOrganizationListParams>(params?
const { userMemberships, userInvitations, userSuggestions } = params || {};

useAssertWrappedByClerkProvider('useOrganizationList');
useAttemptToEnableOrganizations('useOrganizationList');
// No list keys means this call is not using Organizations; the prompt is for the lists.
useAttemptToEnableOrganizations('useOrganizationList', {
enabled: userMemberships !== undefined || userInvitations !== undefined || userSuggestions !== undefined,
});

const userMembershipsSafeValues = useWithSafeValues(userMemberships, {
initialPage: 1,
Expand Down
10 changes: 7 additions & 3 deletions packages/ui/src/hooks/useOrganizationListInView.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,14 +5,18 @@ import { useInView } from './useInView';

/**
* @internal
*
* `enabled` withholds the list params so the three requests do not start. Defaults on.
*/
export const useOrganizationListInView = () => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(organizationListParams);
export const useOrganizationListInView = ({ enabled = true }: { enabled?: boolean } = {}) => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(
enabled ? organizationListParams : undefined,
);

const { ref } = useInView({
threshold: 0,
onChange: inView => {
if (!inView) {
if (!enabled || !inView) {
return;
}
if (userMemberships.hasNextPage) {
Expand Down
18 changes: 14 additions & 4 deletions packages/ui/src/mosaic/components/button/submit-button.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,19 +302,29 @@ describe('Mosaic SubmitButton spin delay', () => {
expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

// A consumer who already knows the action is slow has nothing to gain by waiting.
// A consumer who already knows the action is slow has nothing to gain by waiting: there is no
// delay left to outlast, so the spinner shows in the render that starts the action rather than a
// timer's.
it('lets the consumer opt out of the delay', () => {
render(
const { rerender } = render(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
spinDelay={{ delay: 1000 }}
>
Save
</SubmitButton>,
);
const hidden = atoms(spinner());

advance(0);
rerender(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
>
Save
</SubmitButton>,
);

expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

Expand Down
21 changes: 21 additions & 0 deletions packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,27 @@ describe('useSpinDelay', () => {
expect(result.current).toBeNull();
});

// Direct feedback on a click has nothing to debounce, so a zero delay must not cost a timer's
// worth of render passes before the spinner appears.
it('surfaces the value in the same pass when there is no delay to wait out', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));

expect(result.current).toBe('a');
});

it('still holds a zero-delay value for minDuration', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
await act(() => rerender({ value: null }));

await advance(199);
expect(result.current).toBe('a');

await advance(1);
expect(result.current).toBeNull();
});

it('swaps to a new value immediately when one replaces another mid-show', async () => {
const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
Expand Down
10 changes: 8 additions & 2 deletions packages/ui/src/mosaic/hooks/useSpinDelay.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';

export interface SpinDelayOptions {
/** Wait this long before showing the value, so quick actions never flash a spinner. */
/** Wait this long before showing the value, so quick actions never flash a spinner. `0` shows it straight away. */
delay?: number;
/** Once shown, keep the value up at least this long, so the spinner never flickers off. */
minDuration?: number;
Expand All@@ -25,11 +25,17 @@ export function useSpinDelay<T>(value: T | null, options: SpinDelayOptions = {})
const shownAt = useRef(0);

useEffect(() => {
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`.
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`. With no
// delay there is nothing to outlast, so it surfaces in this pass rather than a timer's.
if (shown === null) {
if (value === null) {
return;
}
if (delay <= 0) {
shownAt.current = Date.now();
setShown(value);
return;
}
const timer = setTimeout(() => {
shownAt.current = Date.now();
setShown(value);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9e77008
test(ui): cover the connected UserButton end to end
alexcarpenter Aug 3, 2026
f081eb3
feat(ui): close the UserButton popover only when a workspace is picked
alexcarpenter Aug 3, 2026
9ff3048
test(ui): query the account menu trigger as a button
alexcarpenter Aug 3, 2026
73e44b9
refactor(ui): migrate the Mosaic Spinner to StyleX and give it a sm size
alexcarpenter Aug 3, 2026
d997d2a
feat(ui): name the active workspace in the UserButton trigger
alexcarpenter Aug 3, 2026
400451f
feat(ui): let combined UserButton lead with the organization or the a…
alexcarpenter Aug 3, 2026
f15f6f6
test(ui): follow the UserButton active-organization contract in the c…
alexcarpenter Aug 4, 2026
6de655d
test(ui): carry organizationMemberships on the connected UserButton u…
alexcarpenter Aug 4, 2026
60d2261
fix(ui): hold the Mosaic UserButton surface still while an action runs
alexcarpenter Aug 4, 2026
b4af3a4
test(ui): cover the UserButton profile modals end to end
alexcarpenter Aug 5, 2026
6751b12
feat(ui): close the UserButton popover behind whatever it opens
alexcarpenter Aug 5, 2026
09139d7
test(ui): cover the UserButton personal workspace row end to end
alexcarpenter Aug 5, 2026
4ad031b
test(ui): cover the named UserButton personal row end to end
alexcarpenter Aug 5, 2026
6a1842b
test(ui): move UserButton rendering cases down to the view test
alexcarpenter Aug 4, 2026
8822ce5
feat(ui): forward mode to the connected UserButton
alexcarpenter Aug 4, 2026
a0c4c8b
test(ui): cover the UserButton create-organization modal end to end
alexcarpenter Aug 5, 2026
df762fb
test(ui): assert Invite opens the InviteMembers modal
alexcarpenter Aug 5, 2026
9a3ba9a
test(ui): identify Alice's account row by her username
alexcarpenter Aug 5, 2026
dd0893c
test(ui): follow the join button's in-place spinner
alexcarpenter Aug 6, 2026
0716089
test(ui): cover a custom menu item end to end
alexcarpenter Aug 6, 2026
abc6347
fix(ui): make UserButton account-only where organizations are disabled
alexcarpenter Aug 6, 2026
c132c7b
refactor(ui): drive the Mosaic UserButton popover from a state machine
alexcarpenter Aug 7, 2026
fb0a499
refactor(ui): drop comments the integration assertions already carry
alexcarpenter Aug 7, 2026
e48835a
refactor(ui): name the organization mode in full on the connected button
alexcarpenter Aug 7, 2026
3bfa70d
feat(ui): add custom UserProfile pages to the Mosaic UserButton
alexcarpenter Aug 7, 2026
5565f1c
chore(repo): consolidate the branch's changesets into one
alexcarpenter Aug 7, 2026
977b36a
refactor(ui): colocate the user button's page hooks with the button
alexcarpenter Aug 10, 2026
ea61af1
fix(ui): drop the Mosaic UserButton spinner hold when the action clos…
alexcarpenter Aug 10, 2026
83a3958
test(ui): follow the user button rows' aria-disabled through the inte…
alexcarpenter Aug 10, 2026
30adecc
refactor(ui): name the Mosaic UserButton layers for their MVC roles v…
Ephem Aug 24, 2026
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-integration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,16 @@ import { useClerk } from './useClerk';
*
* @internal
*/
export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'useOrganizationList') {
export function useAttemptToEnableOrganizations(
caller: 'useOrganization' | 'useOrganizationList',
{ enabled = true }: { enabled?: boolean } = {},
) {
const clerk = useClerk();
const hasAttempted = useRef(false);

useEffect(() => {
// Guard to not run this effect twice on Clerk resource update
if (hasAttempted.current) {
if (!enabled || hasAttempted.current) {
return;
}

Expand All@@ -23,5 +26,5 @@ export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'use
for: 'organizations',
caller,
});
}, [clerk, caller]);
}, [clerk, caller, enabled]);
}
11 changes: 10 additions & 1 deletion packages/shared/src/react/hooks/useOrganization.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,12 @@ export type UseOrganizationParams = {
* </ul>
*/
invitations?: true | PaginatedHookConfig<GetInvitationsParams>;
/**
* Skip the development prompt that offers to enable Organizations.
*
* @internal
*/
__internal_skipAttemptToEnableOrganizations?: boolean;
};

/**
Expand DownExpand Up@@ -275,10 +281,13 @@ export function useOrganization<T extends UseOrganizationParams>(params?: T): Us
membershipRequests: membershipRequestsListParams,
memberships: membersListParams,
invitations: invitationsListParams,
__internal_skipAttemptToEnableOrganizations,
} = params || {};

useAssertWrappedByClerkProvider('useOrganization');
useAttemptToEnableOrganizations('useOrganization');
useAttemptToEnableOrganizations('useOrganization', {
enabled: !__internal_skipAttemptToEnableOrganizations,
});

const organization = useOrganizationBase();
const session = useSessionBase();
Expand Down
5 changes: 4 additions & 1 deletion packages/shared/src/react/hooks/useOrganizationList.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,7 +253,10 @@ export function useOrganizationList<T extends UseOrganizationListParams>(params?
const { userMemberships, userInvitations, userSuggestions } = params || {};

useAssertWrappedByClerkProvider('useOrganizationList');
useAttemptToEnableOrganizations('useOrganizationList');
// No list keys means this call is not using Organizations; the prompt is for the lists.
useAttemptToEnableOrganizations('useOrganizationList', {
enabled: userMemberships !== undefined || userInvitations !== undefined || userSuggestions !== undefined,
});

const userMembershipsSafeValues = useWithSafeValues(userMemberships, {
initialPage: 1,
Expand Down
10 changes: 7 additions & 3 deletions packages/ui/src/hooks/useOrganizationListInView.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,14 +5,18 @@ import { useInView } from './useInView';

/**
* @internal
*
* `enabled` withholds the list params so the three requests do not start. Defaults on.
*/
export const useOrganizationListInView = () => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(organizationListParams);
export const useOrganizationListInView = ({ enabled = true }: { enabled?: boolean } = {}) => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(
enabled ? organizationListParams : undefined,
);

const { ref } = useInView({
threshold: 0,
onChange: inView => {
if (!inView) {
if (!enabled || !inView) {
return;
}
if (userMemberships.hasNextPage) {
Expand Down
18 changes: 14 additions & 4 deletions packages/ui/src/mosaic/components/button/submit-button.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,19 +302,29 @@ describe('Mosaic SubmitButton spin delay', () => {
expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

// A consumer who already knows the action is slow has nothing to gain by waiting.
// A consumer who already knows the action is slow has nothing to gain by waiting: there is no
// delay left to outlast, so the spinner shows in the render that starts the action rather than a
// timer's.
it('lets the consumer opt out of the delay', () => {
render(
const { rerender } = render(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
spinDelay={{ delay: 1000 }}
>
Save
</SubmitButton>,
);
const hidden = atoms(spinner());

advance(0);
rerender(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
>
Save
</SubmitButton>,
);

expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

Expand Down
21 changes: 21 additions & 0 deletions packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,27 @@ describe('useSpinDelay', () => {
expect(result.current).toBeNull();
});

// Direct feedback on a click has nothing to debounce, so a zero delay must not cost a timer's
// worth of render passes before the spinner appears.
it('surfaces the value in the same pass when there is no delay to wait out', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));

expect(result.current).toBe('a');
});

it('still holds a zero-delay value for minDuration', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
await act(() => rerender({ value: null }));

await advance(199);
expect(result.current).toBe('a');

await advance(1);
expect(result.current).toBeNull();
});

it('swaps to a new value immediately when one replaces another mid-show', async () => {
const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
Expand Down
10 changes: 8 additions & 2 deletions packages/ui/src/mosaic/hooks/useSpinDelay.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';

export interface SpinDelayOptions {
/** Wait this long before showing the value, so quick actions never flash a spinner. */
/** Wait this long before showing the value, so quick actions never flash a spinner. `0` shows it straight away. */
delay?: number;
/** Once shown, keep the value up at least this long, so the spinner never flickers off. */
minDuration?: number;
Expand All@@ -25,11 +25,17 @@ export function useSpinDelay<T>(value: T | null, options: SpinDelayOptions = {})
const shownAt = useRef(0);

useEffect(() => {
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`.
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`. With no
// delay there is nothing to outlast, so it surfaces in this pass rather than a timer's.
if (shown === null) {
if (value === null) {
return;
}
if (delay <= 0) {
shownAt.current = Date.now();
setShown(value);
return;
}
const timer = setTimeout(() => {
shownAt.current = Date.now();
setShown(value);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9e77008
test(ui): cover the connected UserButton end to end
alexcarpenter Aug 3, 2026
f081eb3
feat(ui): close the UserButton popover only when a workspace is picked
alexcarpenter Aug 3, 2026
9ff3048
test(ui): query the account menu trigger as a button
alexcarpenter Aug 3, 2026
73e44b9
refactor(ui): migrate the Mosaic Spinner to StyleX and give it a sm size
alexcarpenter Aug 3, 2026
d997d2a
feat(ui): name the active workspace in the UserButton trigger
alexcarpenter Aug 3, 2026
400451f
feat(ui): let combined UserButton lead with the organization or the a…
alexcarpenter Aug 3, 2026
f15f6f6
test(ui): follow the UserButton active-organization contract in the c…
alexcarpenter Aug 4, 2026
6de655d
test(ui): carry organizationMemberships on the connected UserButton u…
alexcarpenter Aug 4, 2026
60d2261
fix(ui): hold the Mosaic UserButton surface still while an action runs
alexcarpenter Aug 4, 2026
b4af3a4
test(ui): cover the UserButton profile modals end to end
alexcarpenter Aug 5, 2026
6751b12
feat(ui): close the UserButton popover behind whatever it opens
alexcarpenter Aug 5, 2026
09139d7
test(ui): cover the UserButton personal workspace row end to end
alexcarpenter Aug 5, 2026
4ad031b
test(ui): cover the named UserButton personal row end to end
alexcarpenter Aug 5, 2026
6a1842b
test(ui): move UserButton rendering cases down to the view test
alexcarpenter Aug 4, 2026
8822ce5
feat(ui): forward mode to the connected UserButton
alexcarpenter Aug 4, 2026
a0c4c8b
test(ui): cover the UserButton create-organization modal end to end
alexcarpenter Aug 5, 2026
df762fb
test(ui): assert Invite opens the InviteMembers modal
alexcarpenter Aug 5, 2026
9a3ba9a
test(ui): identify Alice's account row by her username
alexcarpenter Aug 5, 2026
dd0893c
test(ui): follow the join button's in-place spinner
alexcarpenter Aug 6, 2026
0716089
test(ui): cover a custom menu item end to end
alexcarpenter Aug 6, 2026
abc6347
fix(ui): make UserButton account-only where organizations are disabled
alexcarpenter Aug 6, 2026
c132c7b
refactor(ui): drive the Mosaic UserButton popover from a state machine
alexcarpenter Aug 7, 2026
fb0a499
refactor(ui): drop comments the integration assertions already carry
alexcarpenter Aug 7, 2026
e48835a
refactor(ui): name the organization mode in full on the connected button
alexcarpenter Aug 7, 2026
3bfa70d
feat(ui): add custom UserProfile pages to the Mosaic UserButton
alexcarpenter Aug 7, 2026
5565f1c
chore(repo): consolidate the branch's changesets into one
alexcarpenter Aug 7, 2026
977b36a
refactor(ui): colocate the user button's page hooks with the button
alexcarpenter Aug 10, 2026
ea61af1
fix(ui): drop the Mosaic UserButton spinner hold when the action clos…
alexcarpenter Aug 10, 2026
83a3958
test(ui): follow the user button rows' aria-disabled through the inte…
alexcarpenter Aug 10, 2026
30adecc
refactor(ui): name the Mosaic UserButton layers for their MVC roles v…
Ephem Aug 24, 2026
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-integration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,16 @@ import { useClerk } from './useClerk';
*
* @internal
*/
export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'useOrganizationList') {
export function useAttemptToEnableOrganizations(
caller: 'useOrganization' | 'useOrganizationList',
{ enabled = true }: { enabled?: boolean } = {},
) {
const clerk = useClerk();
const hasAttempted = useRef(false);

useEffect(() => {
// Guard to not run this effect twice on Clerk resource update
if (hasAttempted.current) {
if (!enabled || hasAttempted.current) {
return;
}

Expand All@@ -23,5 +26,5 @@ export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'use
for: 'organizations',
caller,
});
}, [clerk, caller]);
}, [clerk, caller, enabled]);
}
11 changes: 10 additions & 1 deletion packages/shared/src/react/hooks/useOrganization.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,12 @@ export type UseOrganizationParams = {
* </ul>
*/
invitations?: true | PaginatedHookConfig<GetInvitationsParams>;
/**
* Skip the development prompt that offers to enable Organizations.
*
* @internal
*/
__internal_skipAttemptToEnableOrganizations?: boolean;
};

/**
Expand DownExpand Up@@ -275,10 +281,13 @@ export function useOrganization<T extends UseOrganizationParams>(params?: T): Us
membershipRequests: membershipRequestsListParams,
memberships: membersListParams,
invitations: invitationsListParams,
__internal_skipAttemptToEnableOrganizations,
} = params || {};

useAssertWrappedByClerkProvider('useOrganization');
useAttemptToEnableOrganizations('useOrganization');
useAttemptToEnableOrganizations('useOrganization', {
enabled: !__internal_skipAttemptToEnableOrganizations,
});

const organization = useOrganizationBase();
const session = useSessionBase();
Expand Down
5 changes: 4 additions & 1 deletion packages/shared/src/react/hooks/useOrganizationList.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,7 +253,10 @@ export function useOrganizationList<T extends UseOrganizationListParams>(params?
const { userMemberships, userInvitations, userSuggestions } = params || {};

useAssertWrappedByClerkProvider('useOrganizationList');
useAttemptToEnableOrganizations('useOrganizationList');
// No list keys means this call is not using Organizations; the prompt is for the lists.
useAttemptToEnableOrganizations('useOrganizationList', {
enabled: userMemberships !== undefined || userInvitations !== undefined || userSuggestions !== undefined,
});

const userMembershipsSafeValues = useWithSafeValues(userMemberships, {
initialPage: 1,
Expand Down
10 changes: 7 additions & 3 deletions packages/ui/src/hooks/useOrganizationListInView.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,14 +5,18 @@ import { useInView } from './useInView';

/**
* @internal
*
* `enabled` withholds the list params so the three requests do not start. Defaults on.
*/
export const useOrganizationListInView = () => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(organizationListParams);
export const useOrganizationListInView = ({ enabled = true }: { enabled?: boolean } = {}) => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(
enabled ? organizationListParams : undefined,
);

const { ref } = useInView({
threshold: 0,
onChange: inView => {
if (!inView) {
if (!enabled || !inView) {
return;
}
if (userMemberships.hasNextPage) {
Expand Down
18 changes: 14 additions & 4 deletions packages/ui/src/mosaic/components/button/submit-button.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,19 +302,29 @@ describe('Mosaic SubmitButton spin delay', () => {
expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

// A consumer who already knows the action is slow has nothing to gain by waiting.
// A consumer who already knows the action is slow has nothing to gain by waiting: there is no
// delay left to outlast, so the spinner shows in the render that starts the action rather than a
// timer's.
it('lets the consumer opt out of the delay', () => {
render(
const { rerender } = render(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
spinDelay={{ delay: 1000 }}
>
Save
</SubmitButton>,
);
const hidden = atoms(spinner());

advance(0);
rerender(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
>
Save
</SubmitButton>,
);

expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

Expand Down
21 changes: 21 additions & 0 deletions packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,27 @@ describe('useSpinDelay', () => {
expect(result.current).toBeNull();
});

// Direct feedback on a click has nothing to debounce, so a zero delay must not cost a timer's
// worth of render passes before the spinner appears.
it('surfaces the value in the same pass when there is no delay to wait out', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));

expect(result.current).toBe('a');
});

it('still holds a zero-delay value for minDuration', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
await act(() => rerender({ value: null }));

await advance(199);
expect(result.current).toBe('a');

await advance(1);
expect(result.current).toBeNull();
});

it('swaps to a new value immediately when one replaces another mid-show', async () => {
const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
Expand Down
10 changes: 8 additions & 2 deletions packages/ui/src/mosaic/hooks/useSpinDelay.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';

export interface SpinDelayOptions {
/** Wait this long before showing the value, so quick actions never flash a spinner. */
/** Wait this long before showing the value, so quick actions never flash a spinner. `0` shows it straight away. */
delay?: number;
/** Once shown, keep the value up at least this long, so the spinner never flickers off. */
minDuration?: number;
Expand All@@ -25,11 +25,17 @@ export function useSpinDelay<T>(value: T | null, options: SpinDelayOptions = {})
const shownAt = useRef(0);

useEffect(() => {
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`.
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`. With no
// delay there is nothing to outlast, so it surfaces in this pass rather than a timer's.
if (shown === null) {
if (value === null) {
return;
}
if (delay <= 0) {
shownAt.current = Date.now();
setShown(value);
return;
}
const timer = setTimeout(() => {
shownAt.current = Date.now();
setShown(value);
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
9e77008
test(ui): cover the connected UserButton end to end
alexcarpenter Aug 3, 2026
f081eb3
feat(ui): close the UserButton popover only when a workspace is picked
alexcarpenter Aug 3, 2026
9ff3048
test(ui): query the account menu trigger as a button
alexcarpenter Aug 3, 2026
73e44b9
refactor(ui): migrate the Mosaic Spinner to StyleX and give it a sm size
alexcarpenter Aug 3, 2026
d997d2a
feat(ui): name the active workspace in the UserButton trigger
alexcarpenter Aug 3, 2026
400451f
feat(ui): let combined UserButton lead with the organization or the a…
alexcarpenter Aug 3, 2026
f15f6f6
test(ui): follow the UserButton active-organization contract in the c…
alexcarpenter Aug 4, 2026
6de655d
test(ui): carry organizationMemberships on the connected UserButton u…
alexcarpenter Aug 4, 2026
60d2261
fix(ui): hold the Mosaic UserButton surface still while an action runs
alexcarpenter Aug 4, 2026
b4af3a4
test(ui): cover the UserButton profile modals end to end
alexcarpenter Aug 5, 2026
6751b12
feat(ui): close the UserButton popover behind whatever it opens
alexcarpenter Aug 5, 2026
09139d7
test(ui): cover the UserButton personal workspace row end to end
alexcarpenter Aug 5, 2026
4ad031b
test(ui): cover the named UserButton personal row end to end
alexcarpenter Aug 5, 2026
6a1842b
test(ui): move UserButton rendering cases down to the view test
alexcarpenter Aug 4, 2026
8822ce5
feat(ui): forward mode to the connected UserButton
alexcarpenter Aug 4, 2026
a0c4c8b
test(ui): cover the UserButton create-organization modal end to end
alexcarpenter Aug 5, 2026
df762fb
test(ui): assert Invite opens the InviteMembers modal
alexcarpenter Aug 5, 2026
9a3ba9a
test(ui): identify Alice's account row by her username
alexcarpenter Aug 5, 2026
dd0893c
test(ui): follow the join button's in-place spinner
alexcarpenter Aug 6, 2026
0716089
test(ui): cover a custom menu item end to end
alexcarpenter Aug 6, 2026
abc6347
fix(ui): make UserButton account-only where organizations are disabled
alexcarpenter Aug 6, 2026
c132c7b
refactor(ui): drive the Mosaic UserButton popover from a state machine
alexcarpenter Aug 7, 2026
fb0a499
refactor(ui): drop comments the integration assertions already carry
alexcarpenter Aug 7, 2026
e48835a
refactor(ui): name the organization mode in full on the connected button
alexcarpenter Aug 7, 2026
3bfa70d
feat(ui): add custom UserProfile pages to the Mosaic UserButton
alexcarpenter Aug 7, 2026
5565f1c
chore(repo): consolidate the branch's changesets into one
alexcarpenter Aug 7, 2026
977b36a
refactor(ui): colocate the user button's page hooks with the button
alexcarpenter Aug 10, 2026
ea61af1
fix(ui): drop the Mosaic UserButton spinner hold when the action clos…
alexcarpenter Aug 10, 2026
83a3958
test(ui): follow the user button rows' aria-disabled through the inte…
alexcarpenter Aug 10, 2026
30adecc
refactor(ui): name the Mosaic UserButton layers for their MVC roles v…
Ephem Aug 24, 2026
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-integration.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,13 +7,16 @@ import { useClerk } from './useClerk';
*
* @internal
*/
export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'useOrganizationList') {
export function useAttemptToEnableOrganizations(
caller: 'useOrganization' | 'useOrganizationList',
{ enabled = true }: { enabled?: boolean } = {},
) {
const clerk = useClerk();
const hasAttempted = useRef(false);

useEffect(() => {
// Guard to not run this effect twice on Clerk resource update
if (hasAttempted.current) {
if (!enabled || hasAttempted.current) {
return;
}

Expand All@@ -23,5 +26,5 @@ export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'use
for: 'organizations',
caller,
});
}, [clerk, caller]);
}, [clerk, caller, enabled]);
}
11 changes: 10 additions & 1 deletion packages/shared/src/react/hooks/useOrganization.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,6 +61,12 @@ export type UseOrganizationParams = {
* </ul>
*/
invitations?: true | PaginatedHookConfig<GetInvitationsParams>;
/**
* Skip the development prompt that offers to enable Organizations.
*
* @internal
*/
__internal_skipAttemptToEnableOrganizations?: boolean;
};

/**
Expand DownExpand Up@@ -275,10 +281,13 @@ export function useOrganization<T extends UseOrganizationParams>(params?: T): Us
membershipRequests: membershipRequestsListParams,
memberships: membersListParams,
invitations: invitationsListParams,
__internal_skipAttemptToEnableOrganizations,
} = params || {};

useAssertWrappedByClerkProvider('useOrganization');
useAttemptToEnableOrganizations('useOrganization');
useAttemptToEnableOrganizations('useOrganization', {
enabled: !__internal_skipAttemptToEnableOrganizations,
});

const organization = useOrganizationBase();
const session = useSessionBase();
Expand Down
5 changes: 4 additions & 1 deletion packages/shared/src/react/hooks/useOrganizationList.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -253,7 +253,10 @@ export function useOrganizationList<T extends UseOrganizationListParams>(params?
const { userMemberships, userInvitations, userSuggestions } = params || {};

useAssertWrappedByClerkProvider('useOrganizationList');
useAttemptToEnableOrganizations('useOrganizationList');
// No list keys means this call is not using Organizations; the prompt is for the lists.
useAttemptToEnableOrganizations('useOrganizationList', {
enabled: userMemberships !== undefined || userInvitations !== undefined || userSuggestions !== undefined,
});

const userMembershipsSafeValues = useWithSafeValues(userMemberships, {
initialPage: 1,
Expand Down
10 changes: 7 additions & 3 deletions packages/ui/src/hooks/useOrganizationListInView.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,14 +5,18 @@ import { useInView } from './useInView';

/**
* @internal
*
* `enabled` withholds the list params so the three requests do not start. Defaults on.
*/
export const useOrganizationListInView = () => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(organizationListParams);
export const useOrganizationListInView = ({ enabled = true }: { enabled?: boolean } = {}) => {
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(
enabled ? organizationListParams : undefined,
);

const { ref } = useInView({
threshold: 0,
onChange: inView => {
if (!inView) {
if (!enabled || !inView) {
return;
}
if (userMemberships.hasNextPage) {
Expand Down
18 changes: 14 additions & 4 deletions packages/ui/src/mosaic/components/button/submit-button.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,19 +302,29 @@ describe('Mosaic SubmitButton spin delay', () => {
expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

// A consumer who already knows the action is slow has nothing to gain by waiting.
// A consumer who already knows the action is slow has nothing to gain by waiting: there is no
// delay left to outlast, so the spinner shows in the render that starts the action rather than a
// timer's.
it('lets the consumer opt out of the delay', () => {
render(
const { rerender } = render(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
spinDelay={{ delay: 1000 }}
>
Save
</SubmitButton>,
);
const hidden = atoms(spinner());

advance(0);
rerender(
<SubmitButton
isPending
spinDelay={{ delay: 0 }}
>
Save
</SubmitButton>,
);

expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});

Expand Down
21 changes: 21 additions & 0 deletions packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -76,6 +76,27 @@ describe('useSpinDelay', () => {
expect(result.current).toBeNull();
});

// Direct feedback on a click has nothing to debounce, so a zero delay must not cost a timer's
// worth of render passes before the spinner appears.
it('surfaces the value in the same pass when there is no delay to wait out', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));

expect(result.current).toBe('a');
});

it('still holds a zero-delay value for minDuration', async () => {
const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
await act(() => rerender({ value: null }));

await advance(199);
expect(result.current).toBe('a');

await advance(1);
expect(result.current).toBeNull();
});

it('swaps to a new value immediately when one replaces another mid-show', async () => {
const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
Expand Down
10 changes: 8 additions & 2 deletions packages/ui/src/mosaic/hooks/useSpinDelay.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';

export interface SpinDelayOptions {
/** Wait this long before showing the value, so quick actions never flash a spinner. */
/** Wait this long before showing the value, so quick actions never flash a spinner. `0` shows it straight away. */
delay?: number;
/** Once shown, keep the value up at least this long, so the spinner never flickers off. */
minDuration?: number;
Expand All@@ -25,11 +25,17 @@ export function useSpinDelay<T>(value: T | null, options: SpinDelayOptions = {})
const shownAt = useRef(0);

useEffect(() => {
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`.
// Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`. With no
// delay there is nothing to outlast, so it surfaces in this pass rather than a timer's.
if (shown === null) {
if (value === null) {
return;
}
if (delay <= 0) {
shownAt.current = Date.now();
setShown(value);
return;
}
const timer = setTimeout(() => {
shownAt.current = Date.now();
setShown(value);
Expand Down
Loading
Loading