chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
|
||||
const logger = createLogger('RootLayout')
|
||||
|
||||
const BROWSER_EXTENSION_ATTRIBUTES = [
|
||||
'data-new-gr-c-s-check-loaded',
|
||||
'data-gr-ext-installed',
|
||||
'data-gr-ext-disabled',
|
||||
'data-grammarly',
|
||||
'data-fgm',
|
||||
'data-lt-installed',
|
||||
]
|
||||
|
||||
/**
|
||||
* Checks whether a hydration error is caused by Radix UI's auto-generated IDs
|
||||
* (`aria-controls`, `id`) differing between server and client. This is a known
|
||||
* harmless artifact of React 19's streaming SSR producing different `useId()`
|
||||
* tree paths. The IDs self-correct on first interaction and have no visual or
|
||||
* functional impact since they only link closed (invisible) popover/menu content.
|
||||
*/
|
||||
function isRadixIdMismatch(args: unknown[]): boolean {
|
||||
return args.some(
|
||||
(arg) => typeof arg === 'string' && arg.includes('radix-') && /aria-controls|"\bid\b"/.test(arg)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client component that intercepts console.error to filter hydration errors
|
||||
* caused by browser extensions or Radix UI's `useId()` mismatch.
|
||||
*/
|
||||
export function HydrationErrorHandler() {
|
||||
useEffect(() => {
|
||||
const originalError = console.error
|
||||
console.error = (...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('Hydration')) {
|
||||
const isExtensionError = BROWSER_EXTENSION_ATTRIBUTES.some((attr) =>
|
||||
args.some((arg) => typeof arg === 'string' && arg.includes(attr))
|
||||
)
|
||||
|
||||
if (isExtensionError || isRadixIdMismatch(args)) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.error('Hydration Error', {
|
||||
details: args,
|
||||
componentStack: args.find(
|
||||
(arg) => typeof arg === 'string' && arg.includes('component stack')
|
||||
),
|
||||
})
|
||||
}
|
||||
originalError.apply(console, args)
|
||||
}
|
||||
|
||||
return () => {
|
||||
console.error = originalError
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defaultShouldDehydrateQuery, isServer, QueryClient } from '@tanstack/react-query'
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
retryOnMount: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
dehydrate: {
|
||||
shouldDehydrateQuery: (query) =>
|
||||
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let browserQueryClient: QueryClient | undefined
|
||||
|
||||
/**
|
||||
* Returns a QueryClient instance. On the server, creates a new instance per request.
|
||||
* On the client, reuses a singleton instance.
|
||||
*/
|
||||
export function getQueryClient() {
|
||||
if (isServer) {
|
||||
return makeQueryClient()
|
||||
}
|
||||
if (!browserQueryClient) {
|
||||
browserQueryClient = makeQueryClient()
|
||||
}
|
||||
return browserQueryClient
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('PostHogProvider')
|
||||
|
||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||
const [Provider, setProvider] = useState<React.ComponentType<{
|
||||
client: PostHog
|
||||
children: React.ReactNode
|
||||
}> | null>(null)
|
||||
const clientRef = useRef<PostHog | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const posthogEnabled = getEnv('NEXT_PUBLIC_POSTHOG_ENABLED')
|
||||
const posthogKey = getEnv('NEXT_PUBLIC_POSTHOG_KEY')
|
||||
|
||||
if (!isTruthy(posthogEnabled) || !posthogKey) return
|
||||
|
||||
Promise.all([import('posthog-js'), import('posthog-js/react')])
|
||||
.then(([posthogModule, { PostHogProvider: PHProvider }]) => {
|
||||
const posthog = posthogModule.default
|
||||
if (!posthog.__loaded) {
|
||||
posthog.init(posthogKey, {
|
||||
api_host: '/ingest',
|
||||
ui_host: 'https://us.posthog.com',
|
||||
defaults: '2025-05-24',
|
||||
person_profiles: 'identified_only',
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
capture_performance: false,
|
||||
capture_dead_clicks: false,
|
||||
enable_heatmaps: false,
|
||||
disable_session_recording: true,
|
||||
session_recording: {
|
||||
maskAllInputs: false,
|
||||
maskInputOptions: {
|
||||
password: true,
|
||||
email: false,
|
||||
},
|
||||
recordCrossOriginIframes: false,
|
||||
recordHeaders: false,
|
||||
recordBody: false,
|
||||
},
|
||||
persistence: 'localStorage+cookie',
|
||||
})
|
||||
}
|
||||
clientRef.current = posthog
|
||||
setProvider(() => PHProvider)
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Failed to load PostHog', { error: err })
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (Provider && clientRef.current) {
|
||||
return <Provider client={clientRef.current}>{children}</Provider>
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
|
||||
export { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
|
||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = getQueryClient()
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, useContext } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockGetSession, mockSetActive, mockRequestJson } = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockSetActive: vi.fn(),
|
||||
mockRequestJson: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/auth-client', () => ({
|
||||
client: {
|
||||
getSession: mockGetSession,
|
||||
organization: { setActive: mockSetActive },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/client/request', () => ({
|
||||
requestJson: mockRequestJson,
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
identify: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
startSessionRecording: vi.fn(),
|
||||
sessionRecordingStarted: vi.fn(() => true),
|
||||
},
|
||||
}))
|
||||
|
||||
import type { AppSession } from '@/lib/auth/session-response'
|
||||
import {
|
||||
SessionContext,
|
||||
type SessionHookResult,
|
||||
SessionProvider,
|
||||
} from '@/app/_shell/providers/session-provider'
|
||||
import { sessionKeys, useSessionQuery } from '@/hooks/queries/session'
|
||||
|
||||
/** Deferred promise: lets a test resolve a mocked async call at a chosen moment. */
|
||||
function defer<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
/** Set the jsdom URL search string before rendering the provider. */
|
||||
function setSearch(search: string) {
|
||||
window.history.replaceState({}, '', `/${search}`)
|
||||
}
|
||||
|
||||
const STALE_SESSION: AppSession = {
|
||||
user: { id: 'user-1', email: 'u@x.com', name: 'Stale plan' },
|
||||
session: { id: 's1', userId: 'user-1', activeOrganizationId: 'org-1' },
|
||||
}
|
||||
|
||||
const FRESH_SESSION: AppSession = {
|
||||
user: { id: 'user-1', email: 'u@x.com', name: 'Fresh plan' },
|
||||
session: { id: 's1', userId: 'user-1', activeOrganizationId: 'org-1' },
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
ctx: () => SessionHookResult | null
|
||||
queryClient: QueryClient
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts SessionProvider in a real React 19 root under jsdom with a real
|
||||
* QueryClient, capturing the live context value via a probe consumer.
|
||||
*/
|
||||
function renderProvider(): Harness {
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
const container = document.createElement('div')
|
||||
const root: Root = createRoot(container)
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
|
||||
let latest: SessionHookResult | null = null
|
||||
function Probe() {
|
||||
latest = useContext(SessionContext)
|
||||
return null
|
||||
}
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SessionProvider>
|
||||
<Probe />
|
||||
</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
ctx: () => latest,
|
||||
queryClient,
|
||||
unmount: () => act(() => root.unmount()),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush pending work inside an act() boundary. Drains the microtask queue and
|
||||
* then yields one macrotask tick, so React Query's notifyManager (which can
|
||||
* schedule observer notifications on a timer) and any deferred renders settle
|
||||
* deterministically — microtask-only flushing raced the query→render update.
|
||||
*/
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
}
|
||||
|
||||
/** Repeatedly flush until `predicate` holds or the budget runs out. */
|
||||
async function flushUntil(predicate: () => boolean, attempts = 40) {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
if (predicate()) return
|
||||
await flush()
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the getSession call is the upgrade (disableCookieCache) read. */
|
||||
function isUpgradeCall(arg: unknown): boolean {
|
||||
return Boolean(
|
||||
arg &&
|
||||
typeof arg === 'object' &&
|
||||
'query' in (arg as Record<string, unknown>) &&
|
||||
(arg as { query?: { disableCookieCache?: boolean } }).query?.disableCookieCache === true
|
||||
)
|
||||
}
|
||||
|
||||
describe('useSessionQuery', () => {
|
||||
it('uses an all-rooted key factory and a 5-minute staleTime', () => {
|
||||
expect(sessionKeys.all).toEqual(['session'])
|
||||
expect(sessionKeys.detail()).toEqual(['session', 'detail'])
|
||||
// The hook is exported and reads from the same detail key.
|
||||
expect(typeof useSessionQuery).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setSearch('')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('exposes the contract context shape and the loaded session on a normal load', async () => {
|
||||
mockGetSession.mockResolvedValue({ data: STALE_SESSION })
|
||||
|
||||
const h = renderProvider()
|
||||
await flushUntil(() => h.ctx()?.data != null)
|
||||
|
||||
const ctx = h.ctx()
|
||||
expect(ctx).not.toBeNull()
|
||||
expect(ctx).toMatchObject({
|
||||
data: expect.any(Object),
|
||||
isPending: expect.any(Boolean),
|
||||
error: null,
|
||||
})
|
||||
expect(typeof ctx?.refetch).toBe('function')
|
||||
expect(ctx?.data).toEqual(STALE_SESSION)
|
||||
expect(ctx?.isPending).toBe(false)
|
||||
|
||||
h.unmount()
|
||||
})
|
||||
|
||||
it('upgrade path: fresh disableCookieCache read wins even when the stale mount query resolves LAST', async () => {
|
||||
setSearch('?upgraded=true')
|
||||
|
||||
const mount = defer<{ data: AppSession }>()
|
||||
const upgrade = defer<{ data: AppSession }>()
|
||||
|
||||
mockGetSession.mockImplementation((arg?: unknown) => {
|
||||
if (isUpgradeCall(arg)) return upgrade.promise
|
||||
// Honor the abort signal like the real fetch-backed client: cancelQueries
|
||||
// aborts the in-flight mount read, so it rejects rather than resolving late.
|
||||
const signal = (arg as { fetchOptions?: { signal?: AbortSignal } })?.fetchOptions?.signal
|
||||
signal?.addEventListener('abort', () =>
|
||||
mount.reject(new DOMException('Aborted', 'AbortError'))
|
||||
)
|
||||
return mount.promise
|
||||
})
|
||||
// activeOrganizationId is present, so setActive / listCreatorOrganizations are not reached.
|
||||
|
||||
const h = renderProvider()
|
||||
await flush()
|
||||
|
||||
// Resolve the fresh upgrade read FIRST. The cancelQueries guard runs before
|
||||
// setQueryData, cancelling (aborting) the in-flight stale mount query.
|
||||
await act(async () => {
|
||||
upgrade.resolve({ data: FRESH_SESSION })
|
||||
await Promise.resolve()
|
||||
})
|
||||
await flushUntil(() => h.queryClient.getQueryData(sessionKeys.detail()) != null)
|
||||
|
||||
// Assert on the cache — the contended state cancelQueries + setQueryData
|
||||
// govern. The fresh value wins; the aborted stale mount read never clobbers it.
|
||||
expect(h.queryClient.getQueryData(sessionKeys.detail())).toEqual(FRESH_SESSION)
|
||||
expect(h.queryClient.getQueryData(sessionKeys.detail())).not.toEqual(STALE_SESSION)
|
||||
|
||||
h.unmount()
|
||||
})
|
||||
|
||||
it('upgrade path: a failed fresh read keeps the user signed in and still reconciles plan surfaces', async () => {
|
||||
setSearch('?upgraded=true')
|
||||
|
||||
const mount = defer<{ data: AppSession }>()
|
||||
const upgrade = defer<{ data: AppSession }>()
|
||||
mockGetSession.mockImplementation((arg?: unknown) =>
|
||||
isUpgradeCall(arg) ? upgrade.promise : mount.promise
|
||||
)
|
||||
|
||||
const invalidateSpy = vi.spyOn(QueryClient.prototype, 'invalidateQueries')
|
||||
const invalidatedKeys = () =>
|
||||
invalidateSpy.mock.calls.map(([arg]) => (arg as { queryKey?: unknown[] })?.queryKey)
|
||||
|
||||
const h = renderProvider()
|
||||
await flush()
|
||||
|
||||
// The fresh disableCookieCache read fails.
|
||||
await act(async () => {
|
||||
upgrade.reject(new Error('refresh failed'))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await flush()
|
||||
|
||||
// The normal cookie-cached mount query lands AFTER the failure.
|
||||
await act(async () => {
|
||||
mount.resolve({ data: STALE_SESSION })
|
||||
await Promise.resolve()
|
||||
})
|
||||
await flushUntil(
|
||||
() =>
|
||||
h.queryClient.getQueryData(sessionKeys.detail()) != null &&
|
||||
invalidatedKeys().some((k) => Array.isArray(k) && k[0] === 'subscription')
|
||||
)
|
||||
|
||||
// The valid cookie-cached session is still cached — a failed upgrade refresh
|
||||
// must not sign the user out, and it must not surface as a session error.
|
||||
expect(h.queryClient.getQueryData(sessionKeys.detail())).toEqual(STALE_SESSION)
|
||||
expect(h.queryClient.getQueryState(sessionKeys.detail())?.error ?? null).toBeNull()
|
||||
|
||||
// Plan surfaces read server truth, so they still reconcile after the failure.
|
||||
expect(invalidatedKeys()).toContainEqual(['organizations'])
|
||||
expect(invalidatedKeys()).toContainEqual(['subscription'])
|
||||
|
||||
invalidateSpy.mockRestore()
|
||||
h.unmount()
|
||||
})
|
||||
|
||||
it('strips the upgraded param from the URL', async () => {
|
||||
setSearch('?upgraded=true&keep=1')
|
||||
mockGetSession.mockResolvedValue({ data: FRESH_SESSION })
|
||||
|
||||
const h = renderProvider()
|
||||
await flush()
|
||||
|
||||
expect(window.location.search).not.toContain('upgraded')
|
||||
expect(window.location.search).toContain('keep=1')
|
||||
|
||||
h.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { createContext, useEffect, useMemo } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listCreatorOrganizationsContract } from '@/lib/api/contracts/organizations'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import {
|
||||
type AppSession,
|
||||
extractSessionDataFromAuthClientResult,
|
||||
} from '@/lib/auth/session-response'
|
||||
import { sessionKeys, useSessionQuery } from '@/hooks/queries/session'
|
||||
|
||||
export type SessionHookResult = {
|
||||
data: AppSession
|
||||
isPending: boolean
|
||||
error: Error | null
|
||||
refetch: () => Promise<void>
|
||||
}
|
||||
|
||||
export const SessionContext = createContext<SessionHookResult | null>(null)
|
||||
|
||||
const logger = createLogger('SessionProvider')
|
||||
|
||||
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = useQueryClient()
|
||||
const query = useSessionQuery()
|
||||
const { data, isPending, error, refetch } = query
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const wasUpgraded = params.get('upgraded') === 'true'
|
||||
|
||||
if (!wasUpgraded) {
|
||||
return
|
||||
}
|
||||
|
||||
params.delete('upgraded')
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
|
||||
const refreshAfterUpgrade = async () => {
|
||||
const res = await client.getSession({ query: { disableCookieCache: true } })
|
||||
const fresh = extractSessionDataFromAuthClientResult(res) as AppSession
|
||||
|
||||
if (isCancelled) return null
|
||||
|
||||
await queryClient.cancelQueries({ queryKey: sessionKeys.detail() })
|
||||
queryClient.setQueryData(sessionKeys.detail(), fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
const initializeSession = async () => {
|
||||
let session: AppSession = null
|
||||
try {
|
||||
session = await refreshAfterUpgrade()
|
||||
} catch (e) {
|
||||
logger.warn('Failed to refresh session after subscription upgrade', { error: e })
|
||||
}
|
||||
|
||||
if (isCancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh the plan surfaces even if the cookie-bypass read above failed: they
|
||||
// query server truth (not the session cookie cache), so they still reconcile.
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] })
|
||||
|
||||
const activeOrganizationId = session?.session?.activeOrganizationId ?? null
|
||||
if (!session || activeOrganizationId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const orgData = await requestJson(listCreatorOrganizationsContract, {}).catch(() => null)
|
||||
if (!orgData) return
|
||||
|
||||
const organizationId = orgData.organizations?.[0]?.id
|
||||
|
||||
if (!organizationId || isCancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
await client.organization.setActive({ organizationId })
|
||||
|
||||
if (!isCancelled) {
|
||||
const res = await client.getSession({ query: { disableCookieCache: true } })
|
||||
const fresh = extractSessionDataFromAuthClientResult(res) as AppSession
|
||||
if (!isCancelled) {
|
||||
await queryClient.cancelQueries({ queryKey: sessionKeys.detail() })
|
||||
queryClient.setQueryData(sessionKeys.detail(), fresh)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to activate organization after subscription upgrade', { error })
|
||||
}
|
||||
}
|
||||
|
||||
void initializeSession()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [queryClient])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return
|
||||
|
||||
import('posthog-js')
|
||||
.then(({ default: posthog }) => {
|
||||
try {
|
||||
if (typeof posthog.identify !== 'function') return
|
||||
|
||||
if (data?.user) {
|
||||
posthog.identify(data.user.id, {
|
||||
email: data.user.email,
|
||||
name: data.user.name,
|
||||
email_verified: data.user.emailVerified,
|
||||
created_at: data.user.createdAt,
|
||||
})
|
||||
if (
|
||||
typeof posthog.startSessionRecording === 'function' &&
|
||||
typeof posthog.sessionRecordingStarted === 'function' &&
|
||||
!posthog.sessionRecordingStarted()
|
||||
) {
|
||||
posthog.startSessionRecording()
|
||||
}
|
||||
} else {
|
||||
posthog.reset()
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [data, isPending])
|
||||
|
||||
const value = useMemo<SessionHookResult>(
|
||||
() => ({
|
||||
data: data ?? null,
|
||||
isPending,
|
||||
error,
|
||||
refetch: async () => {
|
||||
await refetch()
|
||||
},
|
||||
}),
|
||||
[data, isPending, error, refetch]
|
||||
)
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import type { ThemeProviderProps } from 'next-themes'
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
// Force light mode on public/marketing pages, allow user preference elsewhere
|
||||
const isLightModePage =
|
||||
pathname === '/' ||
|
||||
pathname.startsWith('/login') ||
|
||||
pathname.startsWith('/signup') ||
|
||||
pathname.startsWith('/reset-password') ||
|
||||
pathname.startsWith('/sso') ||
|
||||
pathname.startsWith('/terms') ||
|
||||
pathname.startsWith('/privacy') ||
|
||||
pathname.startsWith('/invite') ||
|
||||
pathname.startsWith('/verify') ||
|
||||
pathname.startsWith('/changelog') ||
|
||||
pathname.startsWith('/chat') ||
|
||||
pathname.startsWith('/blog') ||
|
||||
pathname.startsWith('/resume') ||
|
||||
pathname.startsWith('/oauth') ||
|
||||
pathname.startsWith('/f/') ||
|
||||
pathname.startsWith('/unsubscribe')
|
||||
|
||||
const forcedTheme = isLightModePage ? 'light' : undefined
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute='class'
|
||||
defaultTheme='system'
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
storageKey='sim-theme'
|
||||
forcedTheme={forcedTheme}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { Tooltip } from '@sim/emcn'
|
||||
|
||||
interface TooltipProviderProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function TooltipProvider({ children }: TooltipProviderProps) {
|
||||
return <Tooltip.Provider>{children}</Tooltip.Provider>
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { PUBLIC_ENV_KEY } from 'next-runtime-env'
|
||||
|
||||
/**
|
||||
* `NEXT_PUBLIC_*` values, captured once at module load (build time / server
|
||||
* start) rather than read per-request - correct on the hosted deployment,
|
||||
* where a build's env never changes between requests. Filter matches
|
||||
* `next-runtime-env`'s own `getPublicEnv()` exactly.
|
||||
*/
|
||||
const HOSTED_PUBLIC_ENV = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => /^NEXT_PUBLIC_/i.test(key))
|
||||
)
|
||||
|
||||
/**
|
||||
* Static, build-time equivalent of `next-runtime-env`'s `<PublicEnvScript>`
|
||||
* for the hosted deployment. It populates `window[PUBLIC_ENV_KEY]` with the
|
||||
* exact same shape `getEnv()` (`lib/core/config/env.ts`) reads client-side,
|
||||
* but without `next-runtime-env`'s unconditional `unstable_noStore()` call -
|
||||
* that call opts the entire app into dynamic rendering, which only pays off
|
||||
* for self-hosted Docker images that re-inject env per deploy without a
|
||||
* rebuild. On hosted, env is fixed per build, so this is safe to render
|
||||
* statically alongside the marketing pages' `revalidate`.
|
||||
*
|
||||
* Escapes `<` in the serialized JSON so an env value containing `</script>`
|
||||
* can't close this tag early and inject markup into every hosted page.
|
||||
*/
|
||||
export function PublicEnvScript() {
|
||||
const serialized = JSON.stringify(HOSTED_PUBLIC_ENV).replace(/</g, '\\u003c')
|
||||
return (
|
||||
<script
|
||||
id='public-env'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window['${PUBLIC_ENV_KEY}'] = ${serialized}`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user