From 126e8dda65bf039d3b307ea236544d7d31625361 Mon Sep 17 00:00:00 2001 From: Bretton <36870434+BrettM86@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:53:26 -0700 Subject: [PATCH] feat(post): optimistically render freshly created posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create flow now assembles a PostView from the form data and hands it to the post page through a one-shot stash, so the redirect renders the post immediately (~250ms incl. the create call) instead of racing the AppView indexer — no getPost round-trip, and the comments fetch is skipped straight to the empty state (asking before the post is indexed 404'd into 'Failed to load comments'). The ?uri= retry poll remains as the fallback for hard reloads. Co-Authored-By: Claude Fable 5 --- src/lib/feature/feeds/feed.svelte.ts | 2 + src/lib/feature/post/form/post-form.svelte.ts | 20 ++++- src/lib/feature/post/fresh-post.ts | 85 +++++++++++++++++++ .../c/[handle=handle]/post/[rkey]/+page.ts | 21 +++-- src/routes/create/post/+page.svelte | 4 + 5 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 src/lib/feature/post/fresh-post.ts diff --git a/src/lib/feature/feeds/feed.svelte.ts b/src/lib/feature/feeds/feed.svelte.ts index af9a868c..22ba1f90 100644 --- a/src/lib/feature/feeds/feed.svelte.ts +++ b/src/lib/feature/feeds/feed.svelte.ts @@ -93,6 +93,8 @@ export interface FeedTypes { postUri: string comments: GetCommentsParams preload?: CovesPostView + /** Preload is the just-created post — skip the comments fetch entirely. */ + freshPost?: boolean /** Poll briefly when the post isn't indexed yet (fresh-create redirect). */ retryUnavailable?: boolean thread: { diff --git a/src/lib/feature/post/form/post-form.svelte.ts b/src/lib/feature/post/form/post-form.svelte.ts index 55e48aec..567948cc 100644 --- a/src/lib/feature/post/form/post-form.svelte.ts +++ b/src/lib/feature/post/form/post-form.svelte.ts @@ -4,7 +4,9 @@ import type { CommunityView, CommunityViewDetailed, CreatePostOutput, + PostView, } from '$lib/api/coves/types' +import { buildFreshPostView } from '$lib/feature/post/fresh-post' export type CommunityFormValue = | CommunityRef @@ -14,6 +16,12 @@ export type CommunityFormValue = /** Result returned from PostFormState.submit(), containing both the API output and community context. */ export interface PostSubmitResult extends CreatePostOutput { community: CommunityFormValue + /** + * Optimistic view of the created post, assembled client-side from the form + * data. Lets the post page render immediately instead of waiting for the + * AppView indexer to catch up with the record write. + */ + post?: PostView } export type PostFormInit = { @@ -77,6 +85,16 @@ export class PostFormState { // CreatePostInput does not accept these fields yet. Wire them up once // the Coves API supports them (this.nsfw, this.altText, this.thumbnail). - return { ...result, community } + return { + ...result, + community, + post: buildFreshPostView({ + output: result, + community, + title: this.title || undefined, + content: this.body || undefined, + url: this.url || undefined, + }), + } } } diff --git a/src/lib/feature/post/fresh-post.ts b/src/lib/feature/post/fresh-post.ts new file mode 100644 index 00000000..429e8557 --- /dev/null +++ b/src/lib/feature/post/fresh-post.ts @@ -0,0 +1,85 @@ +import { + type CreatePostOutput, + type PostView, + parseAtUri, +} from '$lib/api/coves/types' +import { profile } from '$lib/app/auth.svelte' +import type { DID, Handle } from '$lib/types/atproto' + +/** + * One-shot hand-off of a just-created post from the create flow to the post + * page. The AppView indexer can lag behind the record write, so the post page + * would otherwise show "Post unavailable" (or wait on its retry poll) for a + * post the client already knows everything about. + */ +let fresh: PostView | undefined + +/** Stash the optimistic view of a just-created post before navigating to it. */ +export function stashFreshPost(post: PostView): void { + fresh = post +} + +/** + * Take the stashed post if it matches `rkey`. Single-use: the stash is + * cleared on a hit so later visits re-fetch real (indexed) data. + */ +export function takeFreshPost(rkey: string): PostView | undefined { + if (fresh?.rkey !== rkey) return undefined + const post = fresh + fresh = undefined + return post +} + +/** + * Assembles a PostView for a just-created post from the create output and the + * form data. Stats start at zero — true for a brand-new post — and the next + * real fetch replaces this view entirely. Returns undefined when the viewer + * or community lacks the fields a PostView requires (e.g. no handle yet). + */ +export function buildFreshPostView(args: { + output: CreatePostOutput + community: { did: DID; handle?: Handle; name: string; avatar?: string } + title?: string + content?: string + url?: string +}): PostView | undefined { + const viewer = profile.current + if (viewer.type !== 'authenticated') return undefined + + const { output, community } = args + if (!community.handle) return undefined + + const createdAt = new Date().toISOString() + + return { + uri: output.uri, + cid: output.cid, + rkey: parseAtUri(output.uri).rkey, + indexedAt: createdAt, + createdAt, + author: { + did: viewer.did, + handle: viewer.handle, + avatar: viewer.avatar, + }, + community: { + did: community.did, + handle: community.handle, + name: community.name, + avatar: community.avatar, + }, + record: { + $type: 'social.coves.community.post', + community: community.did, + author: viewer.did, + createdAt, + title: args.title, + content: args.content, + }, + embed: args.url + ? { $type: 'social.coves.embed.external', external: { uri: args.url } } + : undefined, + stats: { upvotes: 0, downvotes: 0, score: 0, commentCount: 0 }, + viewer: { saved: false }, + } +} diff --git a/src/routes/c/[handle=handle]/post/[rkey]/+page.ts b/src/routes/c/[handle=handle]/post/[rkey]/+page.ts index 4df154b6..efc8686a 100644 --- a/src/routes/c/[handle=handle]/post/[rkey]/+page.ts +++ b/src/routes/c/[handle=handle]/post/[rkey]/+page.ts @@ -15,6 +15,7 @@ import { feeds, type FeedTypes, } from '$lib/feature/feeds/feed.svelte' +import { takeFreshPost } from '$lib/feature/post/fresh-post' import { buildPostAtUri } from '$lib/feature/post/helpers' /** @@ -61,8 +62,12 @@ export async function load({ params, url, fetch, route }) { } } - // Try feed cache for instant display + // Instant display sources, best first: the optimistic view handed off by + // the create flow (the AppView may not have indexed the record yet), then + // the feed caches when the user navigated from a feed page. + const freshPost = takeFreshPost(params.rkey) const cachedPost = + freshPost ?? findInFeed('/', params.rkey) ?? findInFeed('/c/[handle=handle]', params.rkey) @@ -101,10 +106,15 @@ export async function load({ params, url, fetch, route }) { } } - // Fetch comments in parallel (returned as a promise for streaming) - const commentsPromise = coves({ func: fetch }) - .getComments(p.comments) - .then((r) => r.comments) + // Fetch comments in parallel (returned as a promise for streaming). A + // just-created post definitionally has none — and asking before the + // AppView indexes the post 404s into "Failed to load comments" — so skip + // straight to the empty state. + const commentsPromise = p.freshPost + ? Promise.resolve([]) + : coves({ func: fetch }) + .getComments(p.comments) + .then((r) => r.comments) return { post: result, @@ -135,6 +145,7 @@ export async function load({ params, url, fetch, route }) { limit: 50, }, preload: cachedPost, + freshPost: !!freshPost, retryUnavailable: url.searchParams.has('uri'), thread: { showContext, singleThread, focus }, }), diff --git a/src/routes/create/post/+page.svelte b/src/routes/create/post/+page.svelte index d8a0b26d..957546b5 100644 --- a/src/routes/create/post/+page.svelte +++ b/src/routes/create/post/+page.svelte @@ -13,6 +13,7 @@ PostFormState, type PostSubmitResult, } from '$lib/feature/post/form/post-form.svelte' + import { stashFreshPost } from '$lib/feature/post/fresh-post' import { decodeCrosspostDraft, postLink } from '$lib/feature/post/helpers' import { toast } from 'mono-svelte' import { onDestroy } from 'svelte' @@ -48,6 +49,9 @@ function navigateToPost(result: PostSubmitResult): void { try { + // Hand the optimistic view to the post page so it renders instantly — + // the AppView indexer may not have seen the record yet. + if (result.post) stashFreshPost(result.post) // includeUri=true carries the canonical DID-based AT-URI as ?uri= so the // post page can load immediately — the brand-new record is not yet in any // feed cache, and this avoids a backend handle→DID round-trip. -- 2.51.2