Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
16fdd04
deprecate metadata updates via clerkClient.users.updateUser and user.…
brunol95 May 18, 2026
bf5b092
update api version in test
brunol95 May 19, 2026
aebfef5
user.update refresh user object on metadata only updates
brunol95 May 20, 2026
926d666
fix: isPlainObject rejects Date,Map, class interfaces
brunol95 May 20, 2026
c4ccc16
chore: fix changeset formatting
wobsoriano May 20, 2026
34c0df9
Merge branch 'main' into bruno/user-5312-deprecate-update-user-metada…
wobsoriano May 20, 2026
34c3fc8
address lint
brunol95 May 20, 2026
e0df27e
Merge branch 'bruno/user-5312-deprecate-update-user-metadata-fields' …
brunol95 May 20, 2026
4cf3a28
change supported version to latest for testing
brunol95 May 20, 2026
d63df08
remove unused import
brunol95 May 20, 2026
90e5e32
clerk-js: use console.warn instead of deprecated in user.update
brunol95 May 20, 2026
b30b0fa
chore: Use proper Clerk dev instance check
wobsoriano May 20, 2026
a6fc584
chore: Mock BaseResource.clerk
wobsoriano May 20, 2026
0e1a541
chore: test warning message
wobsoriano May 20, 2026
a458bb9
chore: lint fix
wobsoriano May 20, 2026
f4c1ea9
Merge branch 'main' into bruno/user-5312-deprecate-update-user-metada…
wobsoriano May 27, 2026
d0ab1d3
Merge branch 'main' into bruno/user-5312-deprecate-update-user-metada…
brunol95 Jun 9, 2026
d03eca9
update supported bapi and fapi versions to latest
brunol95 Jun 10, 2026
c8e9d9e
Merge branch 'main' into bruno/user-5312-deprecate-update-user-metada…
wobsoriano Jun 11, 2026
626bce4
Merge branch 'main' into bruno/user-5312-deprecate-update-user-metada…
brunol95 Jun 11, 2026
d7e6050
update bundle maxSize
brunol95 Jun 11, 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
23 changes: 23 additions & 0 deletions .changeset/deprecate-update-user-metadata.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's split this into separate changesets so each package changelog stays focused 👍🏼

  1. for backend
  2. for clerk-js and shared

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@clerk/backend': minor
---

Add `clerkClient.users.replaceUserMetadata(userId, params)` for replacing a user's metadata fields in full.

Use `replaceUserMetadata` when the provided metadata should become the complete value for that metadata field:

```ts
await clerkClient.users.replaceUserMetadata(userId, {
publicMetadata: { plan: 'pro' },
});
```

Use `clerkClient.users.updateUserMetadata(userId, params)` when you want to partially update metadata with deep-merge semantics:

```ts
await clerkClient.users.updateUserMetadata(userId, {
publicMetadata: { onboardingComplete: true },
});
```

The `publicMetadata`, `privateMetadata`, and `unsafeMetadata` parameters on `clerkClient.users.updateUser()` are now deprecated. They continue to work, but new code should use `updateUserMetadata()` for partial updates or `replaceUserMetadata()` for full replacement.
24 changes: 24 additions & 0 deletions .changeset/route-unsafe-metadata-to-merge-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@clerk/clerk-js': patch
'@clerk/shared': patch
---

Deprecate passing `unsafeMetadata` to `user.update()`.

Use `user.updateMetadata()` when you want to partially update unsafe metadata with deep-merge semantics:

```ts
await user.updateMetadata({
unsafeMetadata: { onboardingComplete: true },
});
```

`user.update({ unsafeMetadata })` continues to work for now and preserves its existing full-replacement behavior:

```ts
await user.update({
unsafeMetadata: { theme: 'dark' },
});
```

New code should prefer `user.updateMetadata({ unsafeMetadata })` for metadata-only updates.
101 changes: 101 additions & 0 deletions integration/tests/unsafeMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,105 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('unsafeMet

await fakeUser.deleteIfExists();
});

// Helper: sign up a user via the UI and return the BAPI user id once the
// client session is established. Mirrors the existing sign-up test flow so
// these specs share the same baseline (`unsafeMetadata: { position: 'goalie' }`).
const signUpAndGetUser = async ({ page, context }: { page: any; context: any }) => {
const u = createTestUtils({ app, page, context });
const fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
withPhoneNumber: true,
withUsername: true,
});

await u.po.signUp.goTo();
await u.po.signUp.signUpWithEmailAndPassword({
email: fakeUser.email,
password: fakeUser.password,
});
await u.po.signUp.enterTestOtpCode();
await u.po.expect.toBeSignedIn();

const bapiUser = await u.services.users.getUser({ email: fakeUser.email });
expect(bapiUser?.unsafeMetadata).toEqual({ position: 'goalie' });

return { u, fakeUser, bapiUser: bapiUser! };
};

test('user.update({ unsafeMetadata }) preserves replace semantics end-to-end', async ({ page, context }) => {
const { u, fakeUser, bapiUser } = await signUpAndGetUser({ page, context });

// Drive the deprecated path from the browser. The SDK should route
// metadata through PATCH /v1/me/metadata after computing a merge patch
// against the locally-cached value; the server-side outcome must match
// a true replace (the original `position` key is gone).
await page.evaluate(async () => {
await window.Clerk.user.update({ unsafeMetadata: { city: 'Toronto' } });
});

const refreshed = await u.services.users.getUser({ id: bapiUser.id });
expect(refreshed?.unsafeMetadata).toEqual({ city: 'Toronto' });

await fakeUser.deleteIfExists();
});

test('user.updateMetadata({ unsafeMetadata }) deep-merges (recommended path)', async ({ page, context }) => {
const { u, fakeUser, bapiUser } = await signUpAndGetUser({ page, context });

// The recommended migration target. Unlike `update(...)`, this is a
// partial update — the original `position` key must survive.
await page.evaluate(async () => {
await window.Clerk.user.updateMetadata({ unsafeMetadata: { city: 'Toronto' } });
});

const refreshed = await u.services.users.getUser({ id: bapiUser.id });
expect(refreshed?.unsafeMetadata).toEqual({ position: 'goalie', city: 'Toronto' });

await fakeUser.deleteIfExists();
});

test('user.update with metadata + non-metadata fields persists both', async ({ page, context }) => {
const { u, fakeUser, bapiUser } = await signUpAndGetUser({ page, context });

// Mixed call: PATCH /v1/me for the non-metadata field, then
// PATCH /v1/me/metadata for the computed patch. Both must land.
await page.evaluate(async () => {
await window.Clerk.user.update({
firstName: 'Updated',
unsafeMetadata: { city: 'Toronto' },
});
});

const refreshed = await u.services.users.getUser({ id: bapiUser.id });
expect(refreshed?.firstName).toBe('Updated');
expect(refreshed?.unsafeMetadata).toEqual({ city: 'Toronto' });

await fakeUser.deleteIfExists();
});

test('user.update reloads before diffing so server-side mutations are not lost', async ({ page, context }) => {
const { u, fakeUser, bapiUser } = await signUpAndGetUser({ page, context });

// Simulate a server-side mutation made by *another* actor
// after the browser cached the user.
// The browser's local `unsafeMetadata` is now stale,
// missing the `adminAdded` key.
await u.services.clerk.users.updateUserMetadata(bapiUser.id, {
unsafeMetadata: { adminAdded: 'yes' },
});

// From the browser, call the deprecated path with replace intent.
// Without the pre-diff reload, the SDK would diff against stale `{ position: 'goalie' }`
// send `{ position: null, city: 'Toronto' }`, and the server-side `adminAdded` would silently survive violating replace semantics.
// The reload makes the SDK observe the fresh state and null-delete the server-added key too.
await page.evaluate(async () => {
await window.Clerk.user.update({ unsafeMetadata: { city: 'Toronto' } });
});

const refreshed = await u.services.users.getUser({ id: bapiUser.id });
expect(refreshed?.unsafeMetadata).toEqual({ city: 'Toronto' });

await fakeUser.deleteIfExists();
});
});
179 changes: 179 additions & 0 deletions packages/backend/src/api/__tests__/UserApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it, vi } from 'vitest';

import { server, validateHeaders } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('UserAPI', () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'deadbeef',
});

const mockUserResponse = {
object: 'user',
id: 'user_123',
public_metadata: {},
private_metadata: {},
unsafe_metadata: {},
};

describe('updateUser', () => {
it('calls PATCH /users/{id} when no metadata fields are provided', async () => {
const patchHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({ first_name: 'Jane' });
return HttpResponse.json(mockUserResponse);
});

server.use(http.patch('https://api.clerk.test/v1/users/user_123', validateHeaders(patchHandler)));

const response = await apiClient.users.updateUser('user_123', { firstName: 'Jane' });

expect(patchHandler).toHaveBeenCalledTimes(1);
expect(response.id).toBe('user_123');
});

it('routes metadata to PUT /users/{id}/metadata when only metadata is provided', async () => {
const patchHandler = vi.fn(() => HttpResponse.json(mockUserResponse));
const putHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({
public_metadata: { foo: 'bar' },
});
return HttpResponse.json({
...mockUserResponse,
public_metadata: { foo: 'bar' },
});
});

server.use(
http.patch('https://api.clerk.test/v1/users/user_123', validateHeaders(patchHandler)),
http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)),
);

const response = await apiClient.users.updateUser('user_123', {
publicMetadata: { foo: 'bar' },
});

expect(patchHandler).not.toHaveBeenCalled();
expect(putHandler).toHaveBeenCalledTimes(1);
expect(response.publicMetadata).toEqual({ foo: 'bar' });
});

it('splits mixed calls: PATCH for non-metadata, then PUT for metadata', async () => {
const calls: string[] = [];

const patchHandler = vi.fn(async ({ request }: { request: Request }) => {
calls.push('patch');
const body = await request.json();
expect(body).toEqual({ first_name: 'Jane' });
return HttpResponse.json(mockUserResponse);
});

const putHandler = vi.fn(async ({ request }: { request: Request }) => {
calls.push('put');
const body = await request.json();
expect(body).toEqual({
public_metadata: { plan: 'pro' },
private_metadata: { invoice: 'inv_1' },
});
return HttpResponse.json({
...mockUserResponse,
first_name: 'Jane',
public_metadata: { plan: 'pro' },
private_metadata: { invoice: 'inv_1' },
});
});

server.use(
http.patch('https://api.clerk.test/v1/users/user_123', validateHeaders(patchHandler)),
http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)),
);

const response = await apiClient.users.updateUser('user_123', {
firstName: 'Jane',
publicMetadata: { plan: 'pro' },
privateMetadata: { invoice: 'inv_1' },
});

expect(patchHandler).toHaveBeenCalledTimes(1);
expect(putHandler).toHaveBeenCalledTimes(1);
// PATCH must run before PUT so the user state from PUT is the latest.
expect(calls).toEqual(['patch', 'put']);
expect(response.firstName).toBe('Jane');
expect(response.publicMetadata).toEqual({ plan: 'pro' });
});

it('passes only metadata fields that were explicitly provided to PUT', async () => {
const putHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = (await request.json()) as Record<string, unknown>;
// Only unsafe_metadata was provided. The other two should be undefined,
// which serializes to "field omitted" on the wire — leaving those
// columns untouched server-side.
expect(body.unsafe_metadata).toEqual({ device: 'mobile' });
expect(body).not.toHaveProperty('public_metadata');
expect(body).not.toHaveProperty('private_metadata');
return HttpResponse.json({
...mockUserResponse,
unsafe_metadata: { device: 'mobile' },
});
});

server.use(http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)));

await apiClient.users.updateUser('user_123', {
unsafeMetadata: { device: 'mobile' },
});

expect(putHandler).toHaveBeenCalledTimes(1);
});
});

describe('updateUserMetadata', () => {
it('still hits PATCH /users/{id}/metadata (unchanged)', async () => {
const patchHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({
public_metadata: { merge: true },
});
return HttpResponse.json({
...mockUserResponse,
public_metadata: { merge: true },
});
});

server.use(http.patch('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(patchHandler)));

await apiClient.users.updateUserMetadata('user_123', {
publicMetadata: { merge: true },
});

expect(patchHandler).toHaveBeenCalledTimes(1);
});
});

describe('replaceUserMetadata', () => {
it('hits PUT /users/{id}/metadata', async () => {
const putHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({
public_metadata: { replaced: true },
});
return HttpResponse.json({
...mockUserResponse,
public_metadata: { replaced: true },
});
});

server.use(http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)));

const response = await apiClient.users.replaceUserMetadata('user_123', {
publicMetadata: { replaced: true },
});

expect(putHandler).toHaveBeenCalledTimes(1);
expect(response.publicMetadata).toEqual({ replaced: true });
});
});
});
Loading
Loading