Skip to content
Merged
Show file tree
Hide file tree
Changes from 55 commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
be124ea
ci: run E2E against local Supabase in parallel with deploy preview
jon-bell Apr 24, 2026
fed5617
ci: scale Playwright to 16 workers, cache .next/cache
jon-bell Apr 24, 2026
7e4e129
ci: use real GHA secrets in e2e-local, capture supabase logs
jon-bell Apr 25, 2026
0f391c9
ci: trigger workflow on this branch for testing
jon-bell Apr 25, 2026
d72a07a
ci: route e2e-local to pawtograder-e2e runner
jon-bell Apr 25, 2026
9c0fa11
ci: overlap supabase start with build, drop playwright workers to 8
jon-bell Apr 25, 2026
fef7822
ci: drop migration workaround, parallelize playwright install
jon-bell Apr 25, 2026
2eff881
chore: silence dotenv v17 promo tips with quiet: true
jon-bell Apr 25, 2026
b1bf1ff
ci: drop deploy-preview, leave only local-Supabase E2E
jon-bell Apr 25, 2026
4097831
test: assert dialog data-state=closed instead of !visible
jon-bell Apr 25, 2026
e02be44
test: revert overcorrected dialog fixes
jon-bell Apr 25, 2026
17e63e2
test: drop trace=on, keep traces only on retry
jon-bell Apr 25, 2026
b4ed17b
test: stabilize 7 flaky e2e tests across grading + gradebook + discus…
jon-bell Apr 25, 2026
22739a6
test: replace flake-fixing waits with real application signals
jon-bell Apr 25, 2026
eb21139
test: harden 7 CI flakes with strict-mode + realtime-race fixes
jon-bell Apr 25, 2026
6215a15
fix(submissions): await reviewAssignments.refetchAll before finalize …
jon-bell Apr 25, 2026
591519c
test(e2e): retry generateLink with backoff on transient GoTrue errors
jon-bell Apr 25, 2026
50862a4
test(e2e): wait on real hydration signals in three flaky cases
jon-bell Apr 25, 2026
4dc3ae1
test(e2e): fix three races in lab-sections webkit test
jon-bell Apr 26, 2026
3c71ccc
fix(table-controller): await in-flight refetch in refetchAll
jon-bell Apr 26, 2026
db9d0b7
test(e2e): wait for gradebook recalculations to drain before sampling…
jon-bell Apr 26, 2026
750d054
fix(annotations): close line-annotation popover optimistically
jon-bell Apr 26, 2026
21f7f34
fix(gradebook): keep ColumnDef stable across score updates; retry reo…
jon-bell Apr 26, 2026
487428d
fix(table-controller): catch up missed inserts after initialData hydr…
jon-bell Apr 26, 2026
3e118be
test(e2e): gate Complete Self Review on DB row, not just toast
jon-bell Apr 26, 2026
d31246c
test(e2e): assert menu Content data-state=closed instead of positione…
jon-bell Apr 26, 2026
2b0bbb8
test(e2e): gate Self Review Check 2 region on DB row + reload escape …
jon-bell Apr 26, 2026
655ed35
test(e2e): gate Unfollow button on DB watcher row + reload escape hatch
jon-bell Apr 26, 2026
583d8b2
test(e2e): apply same DB-poll Unfollow gate to public-thread reply
jon-bell Apr 26, 2026
1ef6f00
ci: give Supabase 20s grace after bg start exits
jon-bell Apr 26, 2026
7fec782
fix(gradebook): always re-enqueue row recalc on user cell edits
jon-bell Apr 26, 2026
4ff8f58
test(e2e): gate office-hours Submit Request on DB-confirmed help_requ…
jon-bell Apr 26, 2026
41fe985
Revert "fix(gradebook): always re-enqueue row recalc on user cell edits"
jon-bell Apr 26, 2026
9afe7cd
fix(gradebook): re-enqueue dependent recalc on user edits across in-f…
jon-bell Apr 26, 2026
575bfe0
fix(useAllStudentRoles): catch up missed user_roles inserts at hook m…
jon-bell Apr 26, 2026
a32204b
fix(discussion): paint thread heading on first navigation without reload
jon-bell Apr 26, 2026
ea80e6e
test(e2e): drop Self Review Check 2 reload bandaid; getById on-demand…
jon-bell Apr 26, 2026
eb983f6
fix(office-hours): navigate before form-state cleanup to keep router.…
jon-bell Apr 26, 2026
8fa7867
fix(gradebook): hoist SelectedPopoverContent to module scope
jon-bell Apr 26, 2026
f4dc8a2
test(e2e): drop discussion_thread_watchers DB-poll Unfollow bandaids
jon-bell Apr 26, 2026
282f8ab
fix(table-controller): run post-SSR catch-up regardless of auto-refet…
jon-bell Apr 27, 2026
d0f31d9
fix(table-controller): make post-SSR catch-up one-shot per controller…
jon-bell Apr 27, 2026
5723f5d
ci: drop Playwright workers 8→6 after recurring OOM-kills
jon-bell Apr 27, 2026
700633f
ci: isolate e2e-local Supabase per run + cancel superseded runs
jon-bell Apr 27, 2026
39d3633
ci: PR feedback batch — pin supabase CLI, single-line PEM, fail-loud …
jon-bell Apr 27, 2026
76460b7
fix(table-controller): in-flight guard on catchUpSinceWatermark + plu…
jon-bell Apr 27, 2026
69d78ef
test(e2e): retry generateMagicLink rejections, not just error returns
jon-bell Apr 27, 2026
abb02e8
test(e2e): poll DB for self-review review_assignment row before clicking
jon-bell Apr 27, 2026
d266ffb
test(e2e): tighten gradebook column reorder + recalc-idle helpers
jon-bell Apr 27, 2026
526395d
test(e2e): use toHaveCount(0) for closed Add Extension dialog
jon-bell Apr 27, 2026
9e1494e
fix(gradebook): lock pre-update row in cell recalc wrappers
jon-bell Apr 27, 2026
98d2d1b
fix(finalize-early): don't claim finalize failed when only refetch fails
jon-bell Apr 27, 2026
c887670
fix(gradebook): re-run sort/filter accessors after realtime data ticks
jon-bell Apr 27, 2026
0714cda
ci: drop temp ci/parallel-e2e-local-supabase push trigger
jon-bell Apr 27, 2026
eaac2ed
review: PR feedback batch 2 — concurrency, identity, dead state, copy
jon-bell Apr 27, 2026
ae93657
review: PR feedback batch 3 — namespace tmp, drain all pulses, drop r…
jon-bell Apr 27, 2026
769bfe6
fix(gradebook): consolidate cell-recalc migrations into one
jon-bell Apr 27, 2026
7d76254
ci: temporarily re-add branch push trigger for one final validation lap
jon-bell Apr 27, 2026
a0893f1
review: PR feedback batch 4 — null-vs-undefined, stale controller, as…
jon-bell Apr 27, 2026
9c430ee
Drop temp push trigger after final green validation lap
jon-bell Apr 28, 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
280 changes: 239 additions & 41 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Deploy Branch Preview
name: E2E Tests
on:
push:
branches:
Expand All @@ -10,75 +10,273 @@ on:
# Secure baseline: no permissions. Jobs that need them override explicitly.
permissions: {}

# Cancel an in-flight run when a newer one comes in for the same PR (or push
# ref). Under pull_request_target, github.ref resolves to the base branch, so
# keying on it alone would make every PR against staging collide; prefer
# pull_request.number when present so each PR has its own group.
concurrency:
group: e2e-local-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
deploy:
runs-on: pawtograder-ci
environment: staging
# Local-Supabase E2E. Spins up a clean dev environment on the runner and runs
# the Playwright suite against it. The Coolify branch-preview job that used to
# run alongside this one is gone — it'll come back once the deployment target
# moves into the same k8s cluster as this runner.
e2e-local:
runs-on: pawtograder-e2e
timeout-minutes: 75
permissions:
contents: read
packages: write
env:
# Per-run project name so that two e2e-local jobs running on different
# refs (or different runners in the same pool) don't clobber each
# other's Supabase containers / Docker volumes. The Supabase CLI takes
# this from `supabase/config.toml` `project_id`, so we sed-edit the
# checkout copy below before any `supabase` invocation.
SUPABASE_PROJECT: pawtograder-platform-${{ github.run_id }}-${{ github.run_attempt }}
# Supabase local-dev keys are deterministic (signed with the public
# "supabase-demo" JWT secret). Hardcoding lets us write .env.local — and
# therefore start `next build` — before `supabase start` finishes.
LOCAL_SUPABASE_URL: http://127.0.0.1:54321
LOCAL_SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
LOCAL_SUPABASE_SERVICE_ROLE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
Comment thread
coderabbitai[bot] marked this conversation as resolved.
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-node-22-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node-22-
- uses: supabase/setup-cli@v1

- name: Cache Next.js build
uses: actions/cache@v4
with:
version: latest
- name: Deploy a branch preview environment
id: deploy
uses: pawtograder/coolify-supabase-deployment-action@main
path: .next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/*.[jt]s', '**/*.[jt]sx', '!**/node_modules/**') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
nextjs-${{ runner.os }}-

- uses: actions/setup-node@v4
Comment thread
jon-bell marked this conversation as resolved.
Dismissed
with:
node-version: 22

- uses: supabase/setup-cli@v1
with:
ephemeral: false
coolify_api_url: https://coolify.in.ripley.cloud/api/v1
coolify_server_uuid: ${{ secrets.COOLIFY_SERVER_UUID }}
coolify_api_token: ${{ secrets.COOLIFY_API_TOKEN }}
coolify_project_uuid: ${{ secrets.COOLIFY_PROJECT_UUID }}
coolify_environment_name: ${{ vars.COOLIFY_ENVIRONMENT_NAME }}
coolify_environment_uuid: ${{ secrets.COOLIFY_ENVIRONMENT_UUID }}
coolify_supabase_api_url: ${{ secrets.COOLIFY_SUPABASE_API_URL }}
deployment_app_uuid: ${{ secrets.COOLIFY_DEPLOYMENT_APP_UUID }}
bugsink_dsn: ${{ secrets.BUGSINK_DSN }}
discord_webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
frontend_image_repo: ghcr.io/${{ github.repository_owner }}/platform-frontend
dockerfile_path: ./Dockerfile
docker_registry_username: ${{ github.actor }}
docker_registry_password: ${{ secrets.GITHUB_TOKEN }}
# Pin to a known-good version. `latest` makes runs non-reproducible —
# bump deliberately when we want a new CLI.
version: 2.92.1

- name: Install dependencies
run: npm ci

- name: Write .env.local
env:
GITHUB_APP_ID: ${{ secrets.PAWTOGRADER_GITHUB_APP_ID }}
GITHUB_PRIVATE_KEY_STRING: ${{ secrets.PAWTOGRADER_GITHUB_PRIVATE_KEY_STRING }}
GITHUB_OAUTH_CLIENT_ID: ${{ secrets.PAWTOGRADER_GITHUB_OAUTH_CLIENT_ID }}
GITHUB_OAUTH_CLIENT_SECRET: ${{ secrets.PAWTOGRADER_GITHUB_OAUTH_CLIENT_SECRET }}
GITHUB_PRIVATE_KEY_STRING: ${{ secrets.PAWTOGRADER_GITHUB_PRIVATE_KEY_STRING }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }}
UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
run: |
set -euo pipefail
cat > .env.local <<EOF
NEXT_PUBLIC_SUPABASE_URL=${LOCAL_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${LOCAL_SUPABASE_ANON_KEY}
SUPABASE_URL=${LOCAL_SUPABASE_URL}
SUPABASE_ANON_KEY=${LOCAL_SUPABASE_ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY=${LOCAL_SUPABASE_SERVICE_ROLE_KEY}
ENABLE_SIGNUPS=true
NEXT_PUBLIC_PAWTOGRADER_WEB_URL=http://localhost:3001
E2E_ENABLE=true
END_TO_END_SECRET=not-a-secret
EDGE_FUNCTION_SECRET=some-secret-value
GITHUB_APP_ID=${GITHUB_APP_ID}
GITHUB_OAUTH_CLIENT_ID=${GITHUB_OAUTH_CLIENT_ID}
GITHUB_OAUTH_CLIENT_SECRET=${GITHUB_OAUTH_CLIENT_SECRET}
UPSTASH_REDIS_REST_URL=${UPSTASH_REDIS_REST_URL}
UPSTASH_REDIS_REST_TOKEN=${UPSTASH_REDIS_REST_TOKEN}
SENTRY_DSN=${SENTRY_DSN}
EOF
# Single-line escaped PEM — supabase functions serve --env-file does
# not support multi-line quoted values; .env.local.staging uses the
# same shape. The Octokit / GitHub-App SDK unescapes \n at runtime.
KEY_ESCAPED=$(printf '%s' "${GITHUB_PRIVATE_KEY_STRING}" | awk 'BEGIN{ORS=""} {printf "%s\\n", $0}')
printf 'GITHUB_PRIVATE_KEY_STRING="%s"\n' "$KEY_ESCAPED" >> .env.local

# Make supabase/config.toml's project_id unique per run so the supabase
# CLI tags its containers and volumes with our SUPABASE_PROJECT, not the
# repo-default `pawtograder-platform`. Without this, two concurrent runs
# would mutually clobber each other's stack.
- name: Pin supabase project_id per run
run: |
set -euo pipefail
sed -i.bak -E "s|^project_id = .*$|project_id = \"${SUPABASE_PROJECT}\"|" supabase/config.toml
grep "^project_id" supabase/config.toml

# Kick Supabase off in the background so its container pulls + migration
# run overlap with `next build` and `playwright install`.
- name: Start local Supabase (background, fresh)
run: |
set -euo pipefail
npx supabase stop --no-backup || true
docker volume ls --filter label=com.supabase.cli.project=${SUPABASE_PROJECT} -q \
| xargs -r docker volume rm || true

nohup npx supabase start > /tmp/supabase-start.log 2>&1 &
echo $! > /tmp/supabase-start.pid

# Background the browser install (~30-90s, mostly downloads + apt-get).
- name: Install Playwright Browsers (background)
run: |
set -euo pipefail
rm -f /tmp/playwright-install.exitcode
nohup bash -c 'npx playwright install --with-deps \
> /tmp/playwright-install.log 2>&1; \
echo $? > /tmp/playwright-install.exitcode' &

- name: Build Next.js (prod, port 3001)
env:
NEXT_PUBLIC_PAWTOGRADER_WEB_URL: http://localhost:3001
run: npm run build

- name: Wait for Playwright install
run: |
set -euo pipefail
for i in $(seq 1 600); do
[ -f /tmp/playwright-install.exitcode ] && break
sleep 1
done
rc="$(cat /tmp/playwright-install.exitcode 2>/dev/null || echo 'timeout')"
if [ "$rc" != "0" ]; then
echo "playwright install failed (rc=$rc)"
echo '--- playwright-install.log ---'
cat /tmp/playwright-install.log || true
exit 1
fi

- name: Wait for Supabase + finalize schema
run: |
set -euo pipefail
PID="$(cat /tmp/supabase-start.pid)"
# `wait` is useless here — the bg process was launched in a previous
# step's shell and is reparented to PID 1. Poll the API until it
# answers. `supabase start` can exit successfully while kong is
# still warming up, so don't bail on the first PID-gone tick —
# give the API a grace period to come up before declaring failure.
grace_ticks=0
for i in $(seq 1 600); do
if curl -sf -o /dev/null \
-H "apikey: ${LOCAL_SUPABASE_ANON_KEY}" \
"${LOCAL_SUPABASE_URL}/rest/v1/"; then
break
fi
if ! kill -0 "$PID" 2>/dev/null; then
grace_ticks=$((grace_ticks + 1))
if [ "$grace_ticks" -gt 20 ]; then
echo 'supabase start exited and the API never came up within 20s'
echo '--- supabase-start.log ---'
cat /tmp/supabase-start.log || true
exit 1
fi
fi
sleep 1
done
# Final readiness check — fail loudly if we fell out of the loop.
curl -sf -o /dev/null \
-H "apikey: ${LOCAL_SUPABASE_ANON_KEY}" \
"${LOCAL_SUPABASE_URL}/rest/v1/"

# Audit table is partitioned; seed today's partition (and the next 7 days).
docker exec -i "supabase_db_${SUPABASE_PROJECT}" psql -U postgres -d postgres -c \
"SELECT public.audit_maintain_partitions();"

- name: Serve Edge Functions
Comment thread
jon-bell marked this conversation as resolved.
Dismissed
run: |
set -euo pipefail
nohup npx supabase functions serve --env-file .env.local \
> /tmp/edge-functions.log 2>&1 &
echo $! > /tmp/edge-functions.pid
# Wait until the gateway answers. `curl -s -w %{http_code}` prints
# `000` on connect-refused, so check the exit status (or filter
# `000`) — otherwise the loop breaks on the first tick.
ready=0
for i in $(seq 1 60); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:54321/functions/v1/ || true)
if [ -n "$code" ] && [ "$code" != "000" ]; then ready=1; break; fi
sleep 1
done
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if [ "$ready" != "1" ]; then
echo "Edge Functions never became reachable"
tail -n 200 /tmp/edge-functions.log || true
exit 1
fi

- name: Start Next.js prod server
run: |
set -euo pipefail
nohup env PORT=3001 npm run start > /tmp/next-server.log 2>&1 &
echo $! > /tmp/next-server.pid
ready=0
for i in $(seq 1 60); do
if curl -sf -o /dev/null http://localhost:3001/; then ready=1; break; fi
sleep 1
done
if [ "$ready" != "1" ]; then
echo "Next.js prod server never became reachable"
tail -n 200 /tmp/next-server.log || true
exit 1
fi

- name: Run Playwright tests
env:
BASE_URL: http://localhost:3001
SUPABASE_URL: ${{ env.LOCAL_SUPABASE_URL }}
SUPABASE_ANON_KEY: ${{ env.LOCAL_SUPABASE_ANON_KEY }}
SUPABASE_SERVICE_ROLE_KEY: ${{ env.LOCAL_SUPABASE_SERVICE_ROLE_KEY }}
# EDGE_FUNCTION_SECRET / END_TO_END_SECRET are sourced from .env.local
# (written above) via the test-runner's dotenv load — single source
# of truth so the two values can't drift.
TEST_PASSWORD: ${{ secrets.TEST_PASSWORD }}
SUPABASE_URL: ${{ steps.deploy.outputs.supabase_url }}
SUPABASE_SERVICE_ROLE_KEY: ${{ steps.deploy.outputs.supabase_service_role_key }}
SUPABASE_ANON_KEY: ${{ steps.deploy.outputs.supabase_anon_key }}
BASE_URL: ${{ steps.deploy.outputs.app_url }}
EDGE_FUNCTION_SECRET: ${{ steps.deploy.outputs.edge_function_secret }}
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx playwright test

- name: Tail server logs on failure
if: failure()
run: |
echo '--- edge-functions.log ---'; tail -n 500 /tmp/edge-functions.log || true
echo '--- next-server.log ---'; tail -n 500 /tmp/next-server.log || true

- name: Stop background services
if: always()
run: |
kill "$(cat /tmp/edge-functions.pid 2>/dev/null)" 2>/dev/null || true
kill "$(cat /tmp/next-server.pid 2>/dev/null)" 2>/dev/null || true
# Capture full Supabase container logs while the stack is still up.
mkdir -p server-logs
for c in $(docker ps --filter "label=com.supabase.cli.project=${SUPABASE_PROJECT}" --format '{{.Names}}'); do
docker logs "$c" > "server-logs/${c}.log" 2>&1 || true
done
cp -f /tmp/edge-functions.log server-logs/edge-functions.log 2>/dev/null || true
cp -f /tmp/next-server.log server-logs/next-server.log 2>/dev/null || true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
npx supabase stop --no-backup || true

- name: Upload server logs
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: server-logs
path: server-logs/
retention-days: 14

- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,29 @@ export default function FinalizeSubmissionEarly({
});
return;
}
reviewAssignments.refetchAll();
// The submission *is* finalized at this point — the RPC committed the
// state change. Anything that fails after this is post-success cache
// refresh; it must not surface as a "Could not finalize submission"
// error or the user will think the action failed and retry.
toaster.success({
title: "Submission finalized",
description: "Your submission time is set. You can continue with self-review if your course uses it."
});

// Refresh the review_assignments controller so the self-review row
// created by finalize_submission_early is in cache before the UI
// renders the "Complete Self Review" button. Failures here are
// non-fatal (the row will arrive via realtime or the next page load).
try {
await reviewAssignments.refetchAll();
} catch (refetchErr) {
console.warn("Submission finalized, but reviewAssignments refetch failed:", refetchErr);
toaster.create({
type: "warning",
title: "Self-review may take a moment to appear",
description: "Your submission was finalized. If the self-review button doesn't show up, refresh the page."
});
}
} catch (err) {
console.error("Unexpected error finalizing submission:", err);
toaster.error({
Expand Down
13 changes: 13 additions & 0 deletions app/course/[course_id]/discussion/[root_id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,19 @@ function DiscussionPost({ root_id }: { root_id: number }) {
const discussion_topics = useDiscussionTopics();
const { discussionThreadTeasers } = useCourseController();
const rootThread = useTableControllerValueById(discussionThreadTeasers, root_id);
// Force a single-row fetch on mount / root_id change so the heading paints
// reliably on first navigation. The course-wide teasers controller is
// populated by an initial-load query plus realtime broadcasts; if neither
// has delivered this row yet (race when the user navigates before the
// teasers controller's initial fetch resolves, or when realtime delivery
// for a freshly-created thread lags the navigation), getById() returns
// undefined and the page sits on the Skeleton until reload. refetchByIds
// always queries the DB and adds the row, which fires the listener wired
// up by useTableControllerValueById.
useEffect(() => {
discussionThreadTeasers.refetchByIds([root_id]);
}, [discussionThreadTeasers, root_id]);

const [editing, setEditing] = useState(false);
const [visibility, setVisibility] = useState(rootThread?.instructors_only ? "instructors_only" : "all");
const isGraderOrInstructor = useIsGraderOrInstructor();
Expand Down
Loading
Loading