Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion client/www/components/dash/BackToAppsButton.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { ArrowUturnLeftIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { createPortal } from 'react-dom';

export const BackToAppsButton = () => {
const router = useRouter();
const org = router.query.org as string | undefined;
const element = document.getElementById('left-top-bar')!;
if (!element) {
return null;
}
return createPortal(
<Link
className="ml-4 flex items-center gap-2 rounded-xs p-1 px-2 text-sm opacity-70 transition-colors hover:bg-gray-200/50 dark:hover:bg-neutral-700/60"
href="/dash"
href={org ? `/dash?org=${org}` : '/dash'}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
<ArrowUturnLeftIcon width={12} />
Back to Apps
Expand Down
91 changes: 48 additions & 43 deletions client/www/components/dash/MainDashLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,56 @@ import { useRouter } from 'next/router';

export type FetchedDash = ReturnType<typeof useFetchedDash>;

const getInitialWorkspace = () => {
// pull from the "org" query param
const org = new URLSearchParams(window.location.search).get('org');
const WORKSPACE_STORAGE_KEY = 'workspace';

if (org) return org;
if (!window) return 'personal';
const getSavedWorkspace = () =>
typeof window === 'undefined'
? null
: window.localStorage.getItem(WORKSPACE_STORAGE_KEY);

const possibleSaved = window.localStorage.getItem('workspace');

if (possibleSaved) return possibleSaved;
return 'personal';
const saveWorkspace = (workspaceId: string) => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(WORKSPACE_STORAGE_KEY, workspaceId);
}
};

export const { use: useFetchedDash, provider: DashFetchProvider } =
createInitializedContext(
'dashResponse',
(args?: { workspaceId?: string | null | undefined }) => {
const dashResult = useDashFetch();
const [currentWorkspaceId, setWorkspace] = useState<string | 'personal'>(
args?.workspaceId || getInitialWorkspace(),
);
const router = useReadyRouter();

// Read once: the workspace we were in last time, used to reopen a bare
// /dash where you left off. An explicit switch clears it so a restored
// default never overrides a deliberate choice.
const [restoredWorkspace, setRestoredWorkspace] =
useState(getSavedWorkspace);

// The URL is the source of truth. With no `org` we reopen the workspace
// you were last in (a bare entry); an `app` in the URL resolves its own
// workspace (see pages/dash), so we skip the restore then. The devtool
// seeds the workspace via `args`.
const getWorkspaceId = (): string => {
if (router.query.org) return router.query.org as string;
if (args?.workspaceId) return args.workspaceId;
if (restoredWorkspace && !router.query.app) return restoredWorkspace;
return 'personal';
};
const currentWorkspaceId = getWorkspaceId();

const setWorkspace = (
workspaceId: string | 'personal',
opts?: { replace?: boolean },
) => {
setRestoredWorkspace(null);
const query = workspaceId === 'personal' ? {} : { org: workspaceId };
if (opts?.replace) {
router.replace({ query });
} else {
router.push({ query });
}
};

const workspace = useWorkspace(dashResult, currentWorkspaceId);

Expand All @@ -48,43 +77,19 @@ export const { use: useFetchedDash, provider: DashFetchProvider } =
await workspace.mutate();
};

const router = useReadyRouter();
// Remember the current workspace so the next bare /dash can restore it.
useEffect(() => {
saveWorkspace(currentWorkspaceId);
}, [currentWorkspaceId]);

// If we can't load the org (e.g. we were removed from it) fall back to
// the personal account.
useEffect(() => {
if (workspace.error) {
setWorkspace('personal');
setWorkspace('personal', { replace: true });
}
}, [workspace.error]);
Comment on lines +85 to 91

@coderabbitai coderabbitai Bot May 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Only redirect to personal for expected org-access failures.

workspace.error now always rewrites the URL to personal, but the context still exposes only dashResult.error. A transient workspace fetch failure on the personal workspace will never hit the error UI and will sit in the loading state instead. Please gate this fallback to the specific not-found/unauthorized org case and otherwise surface workspace.error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/www/components/dash/MainDashLayout.tsx` around lines 85 - 91, The
current effect unconditionally redirects to personal whenever workspace.error is
truthy; change it to only redirect for expected org-access failures by checking
the error kind/status on workspace.error (e.g., 404 Not Found or 403
Unauthorized / an "not found"/"unauthorized" error indicator) before calling
setWorkspace('personal', { replace: true }); otherwise do not swallow the error
and let the component surface workspace.error (and keep the existing
dashResult.error behavior for personal fetches). Update the useEffect that
references workspace.error to perform the conditional check and only call
setWorkspace for those specific error cases.

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.

I think it's fine to do this, this is how it was already in the code.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Resolve the review comment
gh pr review-comment 2712 --repo instantdb/instant resolve r3314251687 2>/dev/null || \
gh api graphql -f query='
mutation {
  resolveReviewThread(input: {threadId: "PRRT_kwDOMSxPXs5mcbDH"}) {
    thread {
      isResolved
    }
  }
}' 2>/dev/null || echo "Could not auto-resolve via API"

Length of output: 628



@stopachka, acknowledged — the pre-existing behavior is intentional and this PR only adds { replace: true } to the existing fallback. I wasn't able to auto-resolve the comment via the API, but feel free to resolve it manually. Sorry for the noise!


🧠 Learnings used
Learnt from: dwwoelfel
Repo: instantdb/instant PR: 2618
File: client/www/components/dash/WebhookEvents.tsx:85-86
Timestamp: 2026-05-13T21:47:49.260Z
Learning: In this repo, ISN (invalidation sequence number) values have a settled encoding that is designed to be safely represented in URL path segments even when the encoded form can include "/". Therefore, during code review, do not flag raw `isn` interpolation into URL path segments (e.g., `/.../${isn}...`) as a missing `encodeURIComponent` issue in `.tsx` files. Only require additional URL encoding if the interpolation is not using ISN’s settled encoding (i.e., it’s a different value/type than ISN or otherwise not part of the ISN URL-safe scheme).

Learnt from: stopachka
Repo: instantdb/instant PR: 2670
File: client/www/app/getadb/make/generateMarkdown.ts:0-0
Timestamp: 2026-05-15T00:01:01.658Z
Learning: For code reviewed in client/www (Instant’s Next.js app), remember that `output: 'standalone'` in `next.config.js` is only used when `NEXT_PUBLIC_SELF_HOSTED === 'true'`. On the primary Vercel deployment (where standalone is not enabled), runtime reads of co-located static assets (e.g., using `fs.readFile(...)` with `process.cwd()` to reference files alongside the repo) are accessible without requiring `outputFileTracingIncludes`. Only apply concerns about missing standalone output-file tracing / asset bundling for the self-hosted standalone path (the `NEXT_PUBLIC_SELF_HOSTED === 'true'` case).


useEffect(() => {
if (typeof window === 'undefined') return;

window.localStorage.setItem('workspace', currentWorkspaceId);

// Use Next.js router for navigation instead of direct history manipulation
const currentUrl = new URL(window.location.href);

// set the query param
// if its personal remove the query param
if (currentWorkspaceId === 'personal') {
if (currentUrl.searchParams.has('org')) {
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete('org');
router.replace(newUrl.pathname + newUrl.search, undefined, {
shallow: true,
});
}
} else {
if (currentUrl.searchParams.get('org') !== currentWorkspaceId) {
const newUrl = new URL(window.location.href);
newUrl.searchParams.set('org', currentWorkspaceId);
router.replace(newUrl.pathname + newUrl.search, undefined, {
shallow: true,
});
}
}
}, [currentWorkspaceId, router.pathname]);

const addNewAppOptimistically = (
promise: Promise<any>,
app: InstantApp,
Expand Down
16 changes: 3 additions & 13 deletions client/www/components/dash/ProfilePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';
import { useReadyRouter } from '../clientOnlyPage';
import { UserSettingsIcon } from '../icons/UserSettingsIcon';
import {
Button,
Expand All @@ -24,7 +23,6 @@ import { useFlag } from '@/lib/hooks/useFlag';

export const ProfilePanel = () => {
const dashResponse = useFetchedDash();
const router = useReadyRouter();

const email = dashResponse.data.user.email;

Expand Down Expand Up @@ -74,9 +72,8 @@ export const ProfilePanel = () => {
)}
>
<button
onClick={async () => {
onClick={() => {
dashResponse.setWorkspace('personal');
router.push('/dash');
close();
}}
className="grow px-2 py-2 text-left"
Expand Down Expand Up @@ -110,10 +107,6 @@ export const ProfilePanel = () => {
<button
onClick={() => {
dashResponse.setWorkspace(org.id);
router.push({
pathname: '/dash',
query: { org: org.id },
});
close();
}}
className="grow px-2 py-2 text-left"
Expand All @@ -132,11 +125,8 @@ export const ProfilePanel = () => {
<Tooltip>
<TooltipTrigger onClick={() => {}}>
<Link
href="/dash/org"
onClick={() => {
dashResponse.setWorkspace(org.id);
close();
}}
href={`/dash/org?org=${org.id}`}
onClick={() => close()}
>
<div className="p-3 transition-colors hover:bg-gray-200 dark:hover:bg-neutral-600">
<Cog6ToothIcon height={16} width={16} />
Expand Down
66 changes: 36 additions & 30 deletions client/www/pages/dash/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,24 @@ function Dashboard() {
...(cliOauthTicket ? { [cliOauthParamName]: cliOauthTicket } : {}),
};

// Build the canonical /dash query. `org` is how the workspace lives in the
// URL, so it's always included for an org workspace and no call site has to
// remember it. Defaults to the current workspace; pass `workspace` to target
// another. Hand the result to router.replace/push.
const dashQueryParams = (input?: {
app?: string;
tab?: string;
workspace?: string | 'personal';
}) => {
const workspace = input?.workspace ?? fetchedDash.data.currentWorkspaceId;
return {
s: 'main',
...(input?.app ? { app: input.app } : {}),
t: input?.tab ?? tab,
...(workspace !== 'personal' ? { org: workspace } : {}),
};
};

// Local states
const [hideAppId, setHideAppId] = useLocalStorage('hide_app_id', false);

Expand Down Expand Up @@ -340,13 +358,7 @@ function Dashboard() {
useEffect(() => {
if (!app) return;
if (!router.query.app || !router.query.t) {
router.replace({
query: {
s: 'main',
app: app.id,
t: tab,
},
});
router.replace({ query: dashQueryParams({ app: app.id }) });
}
}, [app, router.query.app]);

Expand All @@ -370,13 +382,7 @@ function Dashboard() {
const replaceDefault = () => {
if (!defaultAppId) return;

router.replace({
query: {
s: 'main',
app: defaultAppId,
t: tab,
},
});
router.replace({ query: dashQueryParams({ app: defaultAppId }) });

setLocallySavedApp({
id: defaultAppId,
Expand All @@ -399,19 +405,17 @@ function Dashboard() {
dashResponse.data?.currentWorkspaceId &&
dashResponse.data?.currentWorkspaceId !== 'personal'
) {
dashResponse.setWorkspace('personal');
// The app is personally owned; follow it to the personal account.
router.replace({
query: dashQueryParams({ app: appId, workspace: 'personal' }),
});
} else if (
res?.app?.org_id &&
res?.app?.org_id !== router.query.org
) {
dashResponse.setWorkspace(res.app.org_id);
// The app lives in another org; follow it there.
router.replace({
query: {
s: 'main',
app: appId,
org: res?.app?.org_id,
t: tab,
},
query: dashQueryParams({ app: appId, workspace: res.app.org_id }),
});
} else {
replaceDefault();
Expand Down Expand Up @@ -470,9 +474,7 @@ function Dashboard() {
}

router
.push({
query: q,
})
.push({ query: dashQueryParams({ app: q.app, tab: q.t }) })
.then(() => {
if (opts?.cb) {
opts.cb();
Expand Down Expand Up @@ -537,10 +539,14 @@ function Dashboard() {
}

if (apps.length === 0) {
const workspaceId = dashResponse.data.currentWorkspaceId;
router.replace(
{
pathname: '/dash/new',
query: cliTicketQuery,
query: {
...cliTicketQuery,
...(workspaceId !== 'personal' ? { org: workspaceId } : {}),
},
},
undefined,
{ shallow: true },
Expand Down Expand Up @@ -1035,10 +1041,11 @@ function AppCombobox({
appId: string;
tab: MainTabId;
}) {
const workspaceId = useFetchedDash().data.currentWorkspaceId;
const currentApp = apps.find((a) => a.id === appId) || null;
const [appQuery, setAppQuery] = useState('');
const [pinnedIds, setPinnedIds] = useState(() =>
getPinnedAppIds(localStorage.getItem('workspace')),
getPinnedAppIds(workspaceId),
);
Comment on lines +1044 to 1049

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In React 18, does the initializer passed to useState(() => ...) run again when a prop or context value used inside it changes after the component has already mounted?

💡 Result:

No, the initializer function passed to useState does not run again when a prop or context value changes after the component has mounted [1][2][3]. In React, the initializer function (often called "lazy initialization") is executed only once, during the initial render, to determine the initial state [1][3][4]. After the component has mounted, React ignores this initializer function during subsequent re-renders [1][2][3]. If you need to update the state in response to changes in props or context, you should use the useEffect hook to synchronize your state with those values [5][6][2]. Alternatively, if you want the state to reset entirely when a prop changes, you can use the key prop to force the component to re-mount [6][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="client/www/pages/dash/index.tsx"

echo "=== Context around lines 1020-1080 ==="
nl -ba "$FILE" | sed -n '1010,1090p'

echo
echo "=== Search in file for pinnedIds / setPinnedIds / getPinnedAppIds / workspaceId sync ==="
rg -n "pinnedIds|setPinnedIds|getPinnedAppIds|currentWorkspaceId|workspaceId" "$FILE" || true

Repository: instantdb/instant

Length of output: 161


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="client/www/pages/dash/index.tsx"

echo "=== File existence ==="
test -f "$FILE" && echo "FOUND: $FILE" || (echo "MISSING: $FILE" && exit 1)

echo
echo "=== Context around lines 1010-1090 ==="
awk 'NR>=1010 && NR<=1090 {printf "%d:%s\n", NR, $0}' "$FILE"

echo
echo "=== Search in file for pinnedIds / setPinnedIds / getPinnedAppIds / currentWorkspaceId / workspaceId ==="
rg -n "pinnedIds|setPinnedIds|getPinnedAppIds|currentWorkspaceId|workspaceId" "$FILE" || true

Repository: instantdb/instant

Length of output: 4427


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="client/www/pages/dash/index.tsx"

echo "=== Find AppCombobox usage ==="
rg -n "<AppCombobox|AppCombobox" "$FILE"

echo
echo "=== Inspect surrounding render where AppCombobox is used ==="
# show a small window around each match (best effort)
rg -n "<AppCombobox" "$FILE" | cut -d: -f1 | head -n 20 | while read -r line; do
  start=$((line-40))
  end=$((line+40))
  echo "--- Window around line $line ($start-$end) ---"
  awk -v s="$start" -v e="$end" 'NR>=s && NR<=e {printf "%d:%s\n", NR, $0}' "$FILE"
done

Repository: instantdb/instant

Length of output: 2963


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="client/www/pages/dash/index.tsx"

echo "=== Find Nav usage ==="
rg -n "<Nav\\b" "$FILE"

echo
echo "=== Inspect windows around Nav render sites (best effort) ==="
rg -n "<Nav\\b" "$FILE" | cut -d: -f1 | head -n 20 | while read -r line; do
  start=$((line-60))
  end=$((line+60))
  echo "--- Window around line $line ($start-$end) ---"
  awk -v s="$start" -v e="$end" 'NR>=s && NR<=e {printf "%d:%s\n", NR, $0}' "$FILE"
done

Repository: instantdb/instant

Length of output: 4249


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="client/www/pages/dash/index.tsx"

echo "=== Check top-of-file React imports for useEffect ==="
head -n 80 "$FILE"

echo
echo "=== Search for appId source / updates related to org/workspace switching ==="
rg -n "appId\\b|router\\.query\\.org|currentWorkspaceId|workspaceId" "$FILE" | head -n 80

echo
echo "=== Search for key props related to workspace/org (e.g., key={workspaceId} / org) ==="
rg -n "key=\\{[^}]*workspace|key=\\{[^}]*org|workspaceId\\}" "$FILE" || true

Repository: instantdb/instant

Length of output: 5988


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="client/www/pages/dash/index.tsx"

echo "=== Check top-of-file React imports for useEffect ==="
head -n 120 "$FILE"

echo
echo "=== Search for appId source / updates related to org/workspace switching ==="
rg -n "router\\.query\\.(org|workspace)|currentWorkspaceId|workspaceId|appId\\b" "$FILE" | head -n 120

echo
echo "=== Search for key props related to workspace/org ==="
rg -n "key=\\{[^}]*workspace|key=\\{[^}]*org" "$FILE" || true

Repository: instantdb/instant

Length of output: 7034


🏁 Script executed:

#!/bin/bash
set -euo pipefail
# Find the pinnedApps implementation file
fd -a "pinnedApps" -p . | head -n 50

echo "=== Search for getPinnedAppIds definition ==="
fd -a "*pinnedApps*" client -p . 2>/dev/null | head -n 50
rg -n "function getPinnedAppIds|const getPinnedAppIds|getPinnedAppIds\\s*=" -S . --glob='*.ts' --glob='*.tsx' --glob='*.js' --glob='*.jsx'

# Print the matching file(s)
FILE=$(rg -l "getPinnedAppIds" . --glob='pinnedApps*' --glob='*.ts' --glob='*.tsx' | head -n 1)
echo
echo "=== Showing context from $FILE ==="
[ -n "${FILE:-}" ] && sed -n '1,220p' "$FILE" || true

Repository: instantdb/instant

Length of output: 155


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="client/www/lib/pinnedApps.ts"

echo "=== File head ==="
head -n 120 "$FILE"

echo
echo "=== Search for getPinnedAppIds export in pinnedApps.ts ==="
rg -n "getPinnedAppIds|togglePinnedApp" "$FILE"

echo
echo "=== Show around getPinnedAppIds in pinnedApps.ts ==="
LINE=$(rg -n "getPinnedAppIds" "$FILE" | head -n 1 | cut -d: -f1)
echo "First match line: $LINE"
start=$((LINE-20))
end=$((LINE+60))
awk -v s="$start" -v e="$end" 'NR>=s && NR<=e {printf "%d:%s\n", NR, $0}' "$FILE"

Repository: instantdb/instant

Length of output: 1610


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="client/www/pages/dash/index.tsx"

echo "=== Context around pinned star toggle (approx 1110-1150) ==="
awk 'NR>=1100 && NR<=1160 {printf "%d:%s\n", NR, $0}' "$FILE"

echo
echo "=== Inspect effect around dependency [dashResponse.data?.currentWorkspaceId, appId, router.query.org] (approx 420-470) ==="
awk 'NR>=400 && NR<=480 {printf "%d:%s\n", NR, $0}' "$FILE"

Repository: instantdb/instant

Length of output: 5326


Reset pinned app state when the workspace changes.

In AppCombobox, pinnedIds is set with useState(() => getPinnedAppIds(workspaceId)), but the component isn’t remounted on org/personal switch (no key on workspaceId), so it keeps the previous workspace’s pinned ordering until the user toggles a star.

♻️ Proposed fix
   const [pinnedIds, setPinnedIds] = useState(() =>
     getPinnedAppIds(workspaceId),
   );
+  useEffect(() => {
+    setPinnedIds(getPinnedAppIds(workspaceId));
+  }, [workspaceId]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const workspaceId = useFetchedDash().data.currentWorkspaceId;
const currentApp = apps.find((a) => a.id === appId) || null;
const [appQuery, setAppQuery] = useState('');
const [pinnedIds, setPinnedIds] = useState(() =>
getPinnedAppIds(localStorage.getItem('workspace')),
getPinnedAppIds(workspaceId),
);
const workspaceId = useFetchedDash().data.currentWorkspaceId;
const currentApp = apps.find((a) => a.id === appId) || null;
const [appQuery, setAppQuery] = useState('');
const [pinnedIds, setPinnedIds] = useState(() =>
getPinnedAppIds(workspaceId),
);
useEffect(() => {
setPinnedIds(getPinnedAppIds(workspaceId));
}, [workspaceId]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/www/pages/dash/index.tsx` around lines 1044 - 1049, AppCombobox's
pinnedIds state is initialized from getPinnedAppIds(workspaceId) but isn't reset
when workspaceId changes; update the component to reset pinnedIds whenever
workspaceId changes by either: adding a key={workspaceId} to the component that
renders AppCombobox so it remounts on workspace switch, or (preferably) add a
useEffect inside AppCombobox that watches workspaceId (from
useFetchedDash().data.currentWorkspaceId) and calls
setPinnedIds(getPinnedAppIds(workspaceId)) to reinitialize pinned ordering;
reference pinnedIds, setPinnedIds, getPinnedAppIds, workspaceId, and AppCombobox
when making the change.

const comboboxInputRef = useRef<HTMLInputElement | null>(null);

Expand Down Expand Up @@ -1126,9 +1133,8 @@ function AppCombobox({
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
const wsId = localStorage.getItem('workspace');
togglePinnedApp(app.id, wsId);
setPinnedIds(getPinnedAppIds(wsId));
togglePinnedApp(app.id, workspaceId);
setPinnedIds(getPinnedAppIds(workspaceId));
}}
title={isPinned ? 'Unpin app' : 'Pin app to top'}
>
Expand Down
Loading