refactor: make the public read port async so a network adapter can implement it

`PublicContentQueries` returned arrays, not promises. That signature is only
implementable by something already in memory, so the port could hold exactly
one adapter — the bundled fixture — and no amount of configuration could put
the public site on the backend. Turning it async is the change that makes a
second adapter possible; the adapter itself follows.

The markup is untouched. Every page reads a value and hands it to a
presentational component, so the shape those components receive is mapped at
the adapter boundary and nothing below the page changes.

Screens load through one query, not one per read. Several pages read in a loop
— the home timeline walks every project for its activity, the explore filter
walks search results to resolve titles — and a hook per read would mean a
variable number of hooks per render, which React forbids. `usePublicContent`
takes the whole screen's reads as one loader, where a loop is a loop and
`Promise.all` is available; the loops that used to be N sequential lookups now
issue together.

Two places deliberately do not show the loading surface. The explore filter
sits inside a page that already renders one, so a second skeleton would move
the layout under it — it keeps its structure and fills its options in when they
arrive. The search dialog is a type-ahead: re-querying per keystroke would
replace the results with a skeleton on every key, so it loads the catalog once
and applies the same predicate locally.

`usePublicContent` requires an object because `undefined` is how the query
layer says "no result yet". A loader returning the record itself would make a
missing slug indistinguishable from a request in flight, and the page would sit
on a skeleton instead of rendering its not-found route.

Studio's `resolvePublishedLabel` stays synchronous. It is called from inside
the public renderer, so making it async would push awaits through the render
tree; the shell loads the catalog once and the callback remains a lookup.

The component tests now assemble the query providers the running app assembles.
Without them the render throws "No QueryClient set" — not a harness quirk, but
the same failure the app would produce if it were mounted without its query
layer.
This commit is contained in:
DongHyeonka
2026-08-20 16:53:51 +09:00
parent c362ec6100
commit 4566f2d7a8
31 changed files with 564 additions and 202 deletions
@@ -208,14 +208,42 @@ export function searchPublicContent(query: string): SearchablePublicEntity[] {
);
}
/**
* The MOCK source. The functions above stay synchronous — they filter arrays
* that are already in the bundle, and making them async would only add a
* microtask to every fixture test — so the port's async shape is applied here,
* at the adapter boundary, rather than pushed into the query implementations.
*
* `async` rather than `Promise.resolve(...)` so a throw from one of these
* becomes a rejected promise like the HTTP adapter's would, instead of
* escaping synchronously past the caller's await.
*/
export const publicContentQueries = Object.freeze({
listRecords,
getRecord,
getProject,
getRelease,
getProjectRecords,
getProjectDecisions,
getProjectActivity,
getHomeFocusItems,
searchPublicContent,
async listRecords(filters?: RecordFilters) {
return listRecords(filters);
},
async getRecord<K extends RecordKind>(kind: K, slug: string) {
return getRecord(kind, slug);
},
async getProject(slug: string) {
return getProject(slug);
},
async getRelease(version: string) {
return getRelease(version);
},
async getProjectRecords(projectSlug: string) {
return getProjectRecords(projectSlug);
},
async getProjectDecisions(projectSlug: string) {
return getProjectDecisions(projectSlug);
},
async getProjectActivity(projectSlug: string) {
return getProjectActivity(projectSlug);
},
async getHomeFocusItems() {
return getHomeFocusItems();
},
async searchPublicContent(query: string) {
return searchPublicContent(query);
},
}) satisfies PublicContentQueries;
@@ -165,17 +165,31 @@ export type SearchablePublicEntity = {
* The application-facing boundary for the immutable source Public catalog.
* Method signatures intentionally retain the source query argument and return shapes.
*/
/**
* The public read surface.
*
* Every method is async because one of the two adapters behind this port is a
* network client. The other reads a bundled fixture and could answer
* synchronously, but a port has one shape: if the fixture adapter kept the
* synchronous signature, the HTTP adapter could not implement the same port
* and callers written against the fixture would not compile against the
* network.
*
* Failures throw rather than resolving to a Result. That matches the Studio
* gateways, and it lets `useApplicationQuery` classify a rejection once at the
* boundary instead of every caller unwrapping.
*/
export type PublicContentQueries = Readonly<{
listRecords(filters?: RecordFilters): PublicRecord[];
listRecords(filters?: RecordFilters): Promise<PublicRecord[]>;
getRecord<K extends RecordKind>(
kind: K,
slug: string,
): Extract<PublicRecord, { kind: K }> | undefined;
getProject(slug: string): Project | undefined;
getRelease(version: string): Release | undefined;
getProjectRecords(projectSlug: string): PublicRecord[];
getProjectDecisions(projectSlug: string): ProjectDecision[];
getProjectActivity(projectSlug: string): ProjectActivity[];
getHomeFocusItems(): HomeFocusItem[];
searchPublicContent(query: string): SearchablePublicEntity[];
): Promise<Extract<PublicRecord, { kind: K }> | undefined>;
getProject(slug: string): Promise<Project | undefined>;
getRelease(version: string): Promise<Release | undefined>;
getProjectRecords(projectSlug: string): Promise<PublicRecord[]>;
getProjectDecisions(projectSlug: string): Promise<ProjectDecision[]>;
getProjectActivity(projectSlug: string): Promise<ProjectActivity[]>;
getHomeFocusItems(): Promise<HomeFocusItem[]>;
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
}>;
@@ -1,8 +1,7 @@
import { Link, useNavigate } from "react-router-dom";
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ExploreFilterForm({
action,
@@ -18,20 +17,34 @@ export function ExploreFilterForm({
showType?: boolean;
}) {
const navigate = useNavigate();
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const publicRecords = publicContent.listRecords();
const topics = [...new Set(publicRecords.map((record) => record.topic))].sort();
// This form sits inside a page that renders its own loading state, so it does
// not hand back a fallback of its own — that would put a second skeleton
// inside a screen already showing one, and move the layout under it. It
// renders its real structure immediately with empty option lists and fills
// them in when the catalog arrives.
const view = usePublicContent(["tech-log", "explore-filters"], async (queries) => {
const projectPrefix = "/projects/";
const projects = publicContent
.searchPublicContent("")
const [records, entities] = await Promise.all([
queries.listRecords(),
queries.searchPublicContent(""),
]);
const projectSlugs = entities
.filter((entity) => entity.contentType === "PROJECT")
.flatMap((entity) => {
if (!entity.path.startsWith(projectPrefix)) return [];
const item = publicContent.getProject(
decodeURIComponent(entity.path.slice(projectPrefix.length)),
.flatMap((entity) =>
entity.path.startsWith(projectPrefix)
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
: [],
);
return item ? [{ slug: item.slug, title: item.title }] : [];
const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug)));
return {
topics: [...new Set(records.map((record) => record.topic))].sort(),
projects: resolved
.filter((item) => item !== undefined)
.map((item) => ({ slug: item.slug, title: item.title })),
};
});
const topics = view.data?.topics ?? [];
const projects = view.data?.projects ?? [];
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
const selectedTopic = topics.find(
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
@@ -1,8 +1,7 @@
import { useId, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { usePublicContent } from "../use-public-content.tsx";
type SearchDialogProps = {
className?: string;
@@ -19,8 +18,21 @@ export function SearchDialog({
const triggerRef = useRef<HTMLButtonElement>(null);
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLocaleLowerCase("ko-KR");
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const results = publicContent.searchPublicContent(normalizedQuery);
// Keyed on the empty query, then filtered here, rather than one request per
// keystroke. This is a type-ahead: re-querying per character would replace the
// result list with a loading skeleton on every key, which is a worse dialog
// than a stale-free local filter. The predicate is the same one the catalog
// applies for a non-empty query, so the visible result set is unchanged.
const view = usePublicContent(["tech-log", "search", ""], async (queries) => ({
entities: await queries.searchPublicContent(""),
}));
const results = (view.data?.entities ?? []).filter((entity) =>
normalizedQuery
? [entity.title, entity.summary, entity.topic, entity.project, ...(entity.topics ?? [])]
.filter((value): value is string => Boolean(value))
.some((value) => value.toLocaleLowerCase("ko-KR").includes(normalizedQuery))
: true,
);
function open() {
onBeforeOpen?.();
@@ -1,10 +1,9 @@
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { CaseDocumentPage } from "../components/case-document-page.tsx";
import { usePublicContent } from "../use-public-content.tsx";
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
@@ -14,9 +13,12 @@ export function CasePage() {
const { params, search } = useRouteInput<"TECH_LOG_CASE">();
const slug = optionalString(params.slug);
const requestedState = optionalString(search.state);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const record = slug ? publicContent.getRecord("CASE", slug) : undefined;
const view = usePublicContent(["tech-log", "case", slug], async (queries) => ({
record: slug ? await queries.getRecord("CASE", slug) : undefined,
}));
if (!view.ready) return view.fallback;
const { record } = view.data;
if (!record) return <RegisteredNotFoundRoute />;
return (
@@ -1,13 +1,12 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx";
import { usePublicContent } from "../use-public-content.tsx";
const kinds = {
cases: { kind: "CASE", title: "Case", description: "문제를 재현하고 관찰한 값에서 설계 결론까지 따라갑니다." },
@@ -28,18 +27,29 @@ function getKindConfig(value: string | undefined) {
export function ExploreKindPage() {
const { params, search } = useRouteInput<"TECH_LOG_EXPLORE_KIND">();
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const kind = optionalString(params.kind);
const config = getKindConfig(kind);
if (!config) return <RegisteredNotFoundRoute />;
const topic = optionalString(search.topic);
const project = optionalString(search.project);
const records = publicContent.listRecords({
// The unknown-kind check reads as an early return, but it cannot come before
// the query: hooks run unconditionally or React loses the call order. The
// loader short-circuits instead, and the not-found route is chosen below.
const view = usePublicContent(
["tech-log", "explore-kind", config?.kind, topic, project],
async (queries) => ({
records: config
? await queries.listRecords({
kind: config.kind,
...(topic ? { topic } : {}),
...(project ? { project } : {}),
});
})
: [],
}),
);
if (!config) return <RegisteredNotFoundRoute />;
if (!view.ready) return view.fallback;
const { records } = view.data;
return <main id="main-content" className="shell public-index-page">
<header className="public-page-header"><p className="section-kicker">Explore</p><h1>{config.title}</h1><p>{config.description}</p></header>
<ExploreFilterForm action={`/explore/${kind}`} topic={topic} project={project} showType={false} />
@@ -1,9 +1,8 @@
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx";
import { usePublicContent } from "../use-public-content.tsx";
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
@@ -17,13 +16,19 @@ export function ExplorePage() {
const kind = (["CASE", "REFERENCE", "QUESTION"] as const).find(
(item) => item === requestedKind,
) satisfies RecordKind | undefined;
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const records = publicContent.listRecords({
const view = usePublicContent(
["tech-log", "explore", kind, topic, project],
async (queries) => ({
records: await queries.listRecords({
...(kind ? { kind } : {}),
...(topic ? { topic } : {}),
...(project ? { project } : {}),
});
}),
}),
);
if (!view.ready) return view.fallback;
const { records } = view.data;
return <main id="main-content" className="shell public-index-page">
<header className="public-page-header"><p className="section-kicker">Explore</p><h1></h1><p> , .</p></header>
<ExploreFilterForm action="/explore" kind={kind} topic={topic} project={project} />
@@ -1,11 +1,10 @@
import { Link } from "react-router-dom";
import type { PublicContentQueries } from "../../../application/ports/public-content-queries.ts";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
import { normalizeFocus } from "../../../domain/public/focus-state.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { usePublicContent } from "../use-public-content.tsx";
import { FatalErrorState } from "../components/fatal-error-state.tsx";
import { HomeFocus } from "../components/home-focus.tsx";
import {
@@ -40,12 +39,14 @@ function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
const publicRecords = publicContent.listRecords();
async function getLatestEntries(
publicContent: PublicContentQueries,
): Promise<LatestEntry[]> {
const publicRecords = await publicContent.listRecords();
const publicRecordByPath = new Map(
publicRecords.map((record) => [record.path, record]),
);
const searchableEntities = publicContent.searchPublicContent("");
const searchableEntities = await publicContent.searchPublicContent("");
const projectPrefix = "/projects/";
const projectSlugs = searchableEntities
.filter((entity) => entity.contentType === "PROJECT")
@@ -54,10 +55,16 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
: [],
);
const projectTimeline = projectSlugs.flatMap((projectSlug) => {
const project = publicContent.getProject(projectSlug);
// One project at a time would serialise a request per project; issuing them
// together keeps the timeline's cost at its slowest project rather than their
// sum. The flatten below restores the original single-list shape.
const projectTimeline = (
await Promise.all(
projectSlugs.map(async (projectSlug) => {
const project = await publicContent.getProject(projectSlug);
if (!project) return [];
return publicContent.getProjectActivity(projectSlug).map((activity) => {
const activities = await publicContent.getProjectActivity(projectSlug);
return activities.map((activity) => {
const record = publicRecordByPath.get(
activity.recordPath ?? activity.path,
);
@@ -79,13 +86,17 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
path: activity.path,
};
});
});
const releaseTimeline = searchableEntities
}),
)
).flat();
const releaseTimeline = (
await Promise.all(
searchableEntities
.filter((entity) => entity.contentType === "RELEASE")
.flatMap((entity) => {
.map(async (entity) => {
const prefix = "/releases/";
if (!entity.path.startsWith(prefix)) return [];
const release = publicContent.getRelease(
const release = await publicContent.getRelease(
decodeURIComponent(entity.path.slice(prefix.length)),
);
if (!release) return [];
@@ -102,7 +113,9 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
path: release.path,
},
];
});
}),
)
).flat();
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
right.dateTime.localeCompare(left.dateTime),
@@ -113,8 +126,16 @@ export function HomePage() {
const { search } = useRouteInput<"TECH_LOG_HOME">();
const requestedKey = optionalString(search.focus);
const requestedState = optionalString(search.state);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const focusItems = publicContent.getHomeFocusItems();
const view = usePublicContent(["tech-log", "home"], async (queries) => {
const [focusItems, latestEntries] = await Promise.all([
queries.getHomeFocusItems(),
getLatestEntries(queries),
]);
return { focusItems, latestEntries };
});
if (!view.ready) return view.fallback;
const { focusItems, latestEntries } = view.data;
const availableFocusItems = requestedState === "focus-empty" ? [] : focusItems;
const normalizedKey = normalizeFocus(
requestedKey,
@@ -131,8 +152,6 @@ export function HomePage() {
return <FatalErrorState traceId="PREVIEW-HOME-500" />;
}
const latestEntries = getLatestEntries(publicContent);
return (
<main id="main-content">
<section className="shell home-identity" aria-labelledby="home-title">
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
import { usePublicContent } from "../use-public-content.tsx";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
const principles = [
@@ -26,12 +27,15 @@ const currentProjectSlugs = ["backend-skeleton", "auth-lab"] as const;
const topics = ["Backend Architecture", "JPA", "Authentication", "Redis"] as const;
export function ProfilePage() {
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const currentProjects = currentProjectSlugs.flatMap((slug) => {
const project = publicContent.getProject(slug);
return project ? [project] : [];
const view = usePublicContent(["tech-log", "profile"], async (queries) => {
const resolved = await Promise.all(
currentProjectSlugs.map((slug) => queries.getProject(slug)),
);
return { currentProjects: resolved.filter((project) => project !== undefined) };
});
if (!view.ready) return view.fallback;
const { currentProjects } = view.data;
return (
<main id="main-content" className="shell profile-page">
<header className="profile-header">
@@ -1,22 +1,26 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { ProjectPageHeader } from "../components/project-page-header.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ProjectActivityPage() {
const { params } = useRouteInput<"TECH_LOG_PROJECT_ACTIVITY">();
const slug = typeof params.slug === "string" ? params.slug : "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const project = publicContent.getProject(slug);
const view = usePublicContent(["tech-log", "project", slug, "activity"], async (queries) => {
const project = await queries.getProject(slug);
return project
? { project, activity: await queries.getProjectActivity(slug) }
: { project: undefined, activity: [] };
});
if (!view.ready) return view.fallback;
const { project, activity } = view.data;
if (!project) return <RegisteredNotFoundRoute />;
const activity = publicContent.getProjectActivity(slug);
return (
<main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
@@ -1,22 +1,26 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { ProjectPageHeader } from "../components/project-page-header.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ProjectDecisionsPage() {
const { params } = useRouteInput<"TECH_LOG_PROJECT_DECISIONS">();
const slug = typeof params.slug === "string" ? params.slug : "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const project = publicContent.getProject(slug);
const view = usePublicContent(["tech-log", "project", slug, "decisions"], async (queries) => {
const project = await queries.getProject(slug);
return project
? { project, decisions: await queries.getProjectDecisions(slug) }
: { project: undefined, decisions: [] };
});
if (!view.ready) return view.fallback;
const { project, decisions } = view.data;
if (!project) return <RegisteredNotFoundRoute />;
const decisions = publicContent.getProjectDecisions(slug);
return (
<main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={`${project.title} 결정`} />
@@ -1,24 +1,34 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { ProjectPageHeader } from "../components/project-page-header.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ProjectOverviewPage() {
const { params } = useRouteInput<"TECH_LOG_PROJECT">();
const slug = typeof params.slug === "string" ? params.slug : "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const project = publicContent.getProject(slug);
const view = usePublicContent(["tech-log", "project", slug, "overview"], async (queries) => {
const project = await queries.getProject(slug);
if (!project) {
return { project: undefined, records: [], decisions: [], activity: [] };
}
// Three independent reads for one screen: issued together rather than in
// sequence, so the page waits for the slowest instead of their sum.
const [records, decisions, activity] = await Promise.all([
queries.getProjectRecords(slug),
queries.getProjectDecisions(slug),
queries.getProjectActivity(slug),
]);
return { project, records, decisions, activity };
});
if (!view.ready) return view.fallback;
const { project, records, decisions, activity } = view.data;
if (!project) return <RegisteredNotFoundRoute />;
const records = publicContent.getProjectRecords(slug);
const decisions = publicContent.getProjectDecisions(slug);
const activity = publicContent.getProjectActivity(slug);
return (
<main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={project.title} />
@@ -1,21 +1,25 @@
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { ProjectPageHeader } from "../components/project-page-header.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ProjectRecordsPage() {
const { params } = useRouteInput<"TECH_LOG_PROJECT_RECORDS">();
const slug = typeof params.slug === "string" ? params.slug : "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const project = publicContent.getProject(slug);
const view = usePublicContent(["tech-log", "project", slug, "records"], async (queries) => {
const project = await queries.getProject(slug);
return project
? { project, records: await queries.getProjectRecords(slug) }
: { project: undefined, records: [] };
});
if (!view.ready) return view.fallback;
const { project, records } = view.data;
if (!project) return <RegisteredNotFoundRoute />;
const records = publicContent.getProjectRecords(slug);
return (
<main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={`${project.title} 기록`} />
@@ -1,18 +1,20 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ProjectsPage() {
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const projects = publicContent
.searchPublicContent("")
.filter((item) => item.contentType === "PROJECT")
.flatMap((item) => {
const project = publicContent.getProject(item.path.replace("/projects/", ""));
return project ? [project] : [];
const view = usePublicContent(["tech-log", "projects"], async (queries) => {
const entries = (await queries.searchPublicContent("")).filter(
(item) => item.contentType === "PROJECT",
);
const resolved = await Promise.all(
entries.map((item) => queries.getProject(item.path.replace("/projects/", ""))),
);
return { projects: resolved.filter((project) => project !== undefined) };
});
if (!view.ready) return view.fallback;
const { projects } = view.data;
return (
<main
id="main-content"
@@ -1,10 +1,9 @@
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { QuestionDocumentPage } from "../components/question-document-page.tsx";
import { usePublicContent } from "../use-public-content.tsx";
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
@@ -13,9 +12,15 @@ function optionalString(value: unknown): string | undefined {
export function QuestionPage() {
const { params } = useRouteInput<"TECH_LOG_QUESTION">();
const slug = optionalString(params.slug);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const record = slug ? publicContent.getRecord("QUESTION", slug) : undefined;
const view = usePublicContent(
["tech-log", "question", slug],
async (queries) => ({
record: slug ? await queries.getRecord("QUESTION", slug) : undefined,
}),
);
if (!view.ready) return view.fallback;
const { record } = view.data;
return record ? (
<QuestionDocumentPage record={record} />
) : (
@@ -1,10 +1,9 @@
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { ReferenceDocumentPage } from "../components/reference-document-page.tsx";
import { usePublicContent } from "../use-public-content.tsx";
function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
@@ -13,9 +12,15 @@ function optionalString(value: unknown): string | undefined {
export function ReferencePage() {
const { params } = useRouteInput<"TECH_LOG_REFERENCE">();
const slug = optionalString(params.slug);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const record = slug ? publicContent.getRecord("REFERENCE", slug) : undefined;
const view = usePublicContent(
["tech-log", "reference", slug],
async (queries) => ({
record: slug ? await queries.getRecord("REFERENCE", slug) : undefined,
}),
);
if (!view.ready) return view.fallback;
const { record } = view.data;
return record ? (
<ReferenceDocumentPage record={record} />
) : (
@@ -1,18 +1,20 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ReleasePage() {
const { params } = useRouteInput<"TECH_LOG_RELEASE">();
const version = typeof params.version === "string" ? params.version : "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const release = publicContent.getRelease(version);
const view = usePublicContent(["tech-log", "release", version], async (queries) => ({
release: await queries.getRelease(version),
}));
if (!view.ready) return view.fallback;
const { release } = view.data;
if (!release) return <RegisteredNotFoundRoute />;
return (
@@ -1,18 +1,20 @@
import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ReleasesPage() {
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const releases = publicContent
.searchPublicContent("")
.filter((item) => item.contentType === "RELEASE")
.flatMap((item) => {
const release = publicContent.getRelease(item.path.replace("/releases/", ""));
return release ? [release] : [];
const view = usePublicContent(["tech-log", "releases"], async (queries) => {
const entries = (await queries.searchPublicContent("")).filter(
(item) => item.contentType === "RELEASE",
);
const resolved = await Promise.all(
entries.map((item) => queries.getRelease(item.path.replace("/releases/", ""))),
);
return { releases: resolved.filter((release) => release !== undefined) };
});
if (!view.ready) return view.fallback;
const { releases } = view.data;
return (
<main
id="main-content"
@@ -1,8 +1,7 @@
import { Link, useNavigate } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { usePublicContent } from "../use-public-content.tsx";
const labels = { CASE: "Case", REFERENCE: "Reference", QUESTION: "Open Question", PROJECT: "Project", RELEASE: "Release" } as const;
@@ -14,8 +13,9 @@ export function SearchPage() {
const navigate = useNavigate();
const { search } = useRouteInput<"TECH_LOG_SEARCH">();
const query = optionalString(search.q)?.trim() ?? "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const results = publicContent.searchPublicContent(query);
const view = usePublicContent(["tech-log", "search", query], async (queries) => ({
results: await queries.searchPublicContent(query),
}));
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -25,6 +25,9 @@ export function SearchPage() {
void navigate(`/search?q=${encodeURIComponent(nextQuery)}`);
}
if (!view.ready) return view.fallback;
const { results } = view.data;
return <main id="main-content" className="shell search-page">
<header className="public-page-header"><p className="section-kicker">Search</p><h1></h1><p> , , .</p></header>
<form key={query} className="search-page-form" action="/search" method="get" onSubmit={submit}><label><span className="visually-hidden"></span><input type="search" name="q" defaultValue={query} placeholder="검색어를 입력하세요" /></label><button type="submit"></button></form>
@@ -1,10 +1,9 @@
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
import {
RegisteredNotFoundRoute,
useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx";
import { usePublicContent } from "../use-public-content.tsx";
const topics = {
jpa: {
@@ -37,11 +36,17 @@ function topicConfig(value: unknown) {
export function TopicPage() {
const { params } = useRouteInput<"TECH_LOG_TOPIC">();
const topic = topicConfig(params.slug);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
// Hooks run unconditionally, so the unknown-topic case is handled by the
// loader and the not-found route is chosen after it.
const view = usePublicContent(
["tech-log", "topic", topic?.title],
async (queries) =>
topic ? { records: await queries.listRecords({ topic: topic.title }) } : { records: [] },
);
if (!topic) return <RegisteredNotFoundRoute />;
if (!view.ready) return view.fallback;
const records = publicContent.listRecords({ topic: topic.title });
const { records } = view.data;
return (
<main id="main-content" className="shell public-index-page">
<header className="public-page-header">
@@ -0,0 +1,109 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { createFailure } from "../../../../contracts/errors.ts";
import {
LoadingSurface,
TerminalErrorSurface,
} from "../../../../presentation/components/async-surface.tsx";
import { useApplicationQuery } from "../../../../presentation/adapters/query/index.ts";
import { useApplication } from "../../../../presentation/providers/application-provider.tsx";
import type { PublicContentQueries } from "../../application/ports/public-content-queries.ts";
import { TECH_LOG_FEATURE_ID } from "../../application/tech-log-feature-input.ts";
/**
* One query per screen, not one per call.
*
* The public pages were written against a synchronous fixture, so they read
* whatever they needed inline — and several read in a loop: the home timeline
* walks every project for its activity, the explore filter walks search results
* to resolve project titles. Turning each of those into its own hook would mean
* a variable number of hooks per render, which React forbids outright.
*
* So a screen loads everything in one `execute`, where a loop is just a loop and
* `Promise.all` is available. The cost is that a screen waits for its slowest
* read; the benefit is that the page bodies keep computing from plain values and
* the markup is unchanged.
*
* The return is a discriminated union so a page can hand back `view.fallback`
* and have `view.data` narrow to present on the line after — without that, every
* page would need its own non-null assertion.
*/
export type PublicContentView<Value> =
| Readonly<{ ready: false; fallback: ReactNode; data?: undefined }>
| Readonly<{ ready: true; fallback: null; data: Value }>;
/**
* `Value extends object` is load-bearing, not decoration. `undefined` is how the
* query layer says "no result yet", so a loader that returned the record itself
* would make a genuinely missing slug — `getRecord` resolving to `undefined` —
* indistinguishable from a request still in flight, and the page would sit on a
* loading skeleton instead of rendering its not-found route. Wrapping the
* screen's reads in an object keeps the two apart.
*/
export function usePublicContent<Value extends object>(
queryKey: readonly unknown[],
load: (queries: PublicContentQueries) => Promise<Value>,
): PublicContentView<Value> {
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
// `load` is a new closure every render, so depending on it would re-run the
// query forever. The key is the declared identity of the request — the same
// rule the rest of the query layer follows — so the key is what this closes
// over.
const execute = useCallback(
async () => {
try {
return { ok: true as const, value: await load(publicContent) };
} catch (cause) {
return { ok: false as const, error: failureFor(cause) };
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by queryKey, see above
[publicContent, ...queryKey],
);
const query = useApplicationQuery<Value>(
useMemo(() => ({ queryKey, execute }), [execute, queryKey]),
);
if (query.data !== undefined) {
return Object.freeze({ ready: true as const, fallback: null, data: query.data });
}
const failure = query.state.failure;
return Object.freeze({
ready: false as const,
fallback: failure ? (
<TerminalErrorSurface
userMessageKey={failure.userMessageKey}
action={failure.action}
onAction={() => void query.retry()}
/>
) : (
<LoadingSurface />
),
});
}
/**
* Adapters throw. One that knows what went wrong attaches the classified
* failure to the error; anything else arriving here is a defect in this layer
* rather than a server condition, and is not reported as one.
*/
function failureFor(cause: unknown) {
const attached = (cause as { failure?: unknown } | null)?.failure;
if (isAppFailure(attached)) return attached;
return createFailure("UNKNOWN_CLIENT_FAILURE", "TECH_LOG_PUBLIC_CONTENT", 0, {
code: "PUBLIC_CONTENT_UNAVAILABLE",
});
}
function isAppFailure(
value: unknown,
): value is ReturnType<typeof createFailure> {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { kind?: unknown }).kind === "string" &&
typeof (value as { code?: unknown }).code === "string"
);
}
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { usePublicContent } from "../public/use-public-content.tsx";
import { useLocation, useNavigate } from "react-router-dom";
import { useApplication } from "../../../../presentation/providers/application-provider.tsx";
@@ -60,13 +62,20 @@ export function StudioShell({ children }: StudioShellProps) {
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioAssetGateway(),
[application],
);
// `ResolvePublishedLabel` is called from inside the public renderer, which is
// synchronous by design — making it async would push awaits through the whole
// render tree. So the catalog is loaded once here and the callback stays a
// lookup over what has already arrived. Before it arrives the renderer falls
// back to its own "게시 전" label, which is what it showed for an unknown path
// anyway.
const publishedLabels = usePublicContent(
["tech-log", "studio", "published-labels"],
async (queries) => ({ records: await queries.listRecords() }),
);
const records = publishedLabels.data?.records;
const resolvePublishedLabel = useCallback(
(path: string) =>
application.features
.get(TECH_LOG_FEATURE_ID)
.publicContent.listRecords()
.find((record) => record.path === path)?.publishedLabel,
[application],
(path: string) => records?.find((record) => record.path === path)?.publishedLabel,
[records],
);
useEffect(() => {
@@ -49,10 +49,12 @@ const { AppRouter } = await import("../../src/presentation/routes/app-router.tsx
const { ApplicationProvider } = await import(
"../../src/presentation/providers/application-provider.tsx"
);
const { renderWithQueryProviders } = await import("../helpers/query-providers.tsx");
function renderAt(path: string, disabled: boolean) {
window.history.pushState({}, "", path);
return render(
renderWithQueryProviders(
<ApplicationProvider
application={createTestApplication({
session: createAnonymousSessionAdapter(),
@@ -68,6 +70,7 @@ function renderAt(path: string, disabled: boolean) {
>
<AppRouter />
</ApplicationProvider>,
),
);
}
+9
View File
@@ -17,6 +17,7 @@ import { createTechLogFeatureInstalledInput } from "../../src/features/tech-log/
import { MOCK_STUDIO_INSTALL_CONTEXT } from "../helpers/studio-install-context.ts";
import { TECH_LOG_FEATURE_ID } from "../../src/features/tech-log/application/tech-log-feature-input.ts";
import { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
import { renderWithQueryProviders } from "../helpers/query-providers.tsx";
import {
AppRouter,
createGroupedRouteObjects,
@@ -123,6 +124,7 @@ function createSignedInSessionAdapter() {
function renderRouter(session = createAnonymousSessionAdapter()) {
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT);
return render(
renderWithQueryProviders(
<ApplicationProvider
application={createTestApplication({
session,
@@ -131,6 +133,7 @@ function renderRouter(session = createAnonymousSessionAdapter()) {
>
<AppRouter />
</ApplicationProvider>,
),
);
}
@@ -154,6 +157,7 @@ describe("generic application router", () => {
});
render(
renderWithQueryProviders(
<ApplicationProvider application={createTestApplication()}>
<LocaleProvider>
<ThemeProvider>
@@ -163,6 +167,7 @@ describe("generic application router", () => {
</ThemeProvider>
</LocaleProvider>
</ApplicationProvider>,
),
);
expect(
@@ -278,6 +283,7 @@ describe("generic application router", () => {
});
render(
renderWithQueryProviders(
<ApplicationProvider application={createTestApplication()}>
<LocaleProvider>
<ThemeProvider>
@@ -287,6 +293,7 @@ describe("generic application router", () => {
</ThemeProvider>
</LocaleProvider>
</ApplicationProvider>,
),
);
expect(await screen.findByTestId("studio-layout")).toBeVisible();
@@ -325,6 +332,7 @@ describe("generic application router", () => {
});
render(
renderWithQueryProviders(
<ApplicationProvider application={createTestApplication()}>
<LocaleProvider>
<ThemeProvider>
@@ -334,6 +342,7 @@ describe("generic application router", () => {
</ThemeProvider>
</LocaleProvider>
</ApplicationProvider>,
),
);
expect(
@@ -30,6 +30,7 @@ const runtime: Runtime = {
},
FEATURE_OVERRIDES: {},
TECH_LOG_STUDIO_SOURCE: "MOCK",
TECH_LOG_PUBLIC_SOURCE: "MOCK",
},
configSchema: "V2",
build: {
@@ -35,9 +35,11 @@ function inputOf(document: WorkingCopy) {
function installedInputs(
studioSource: "MOCK" | "HTTP" = "MOCK",
publicSource: "MOCK" | "HTTP" = "MOCK",
): InstalledInputs {
return createInstalledFeatureInputs({
studioSource,
publicSource,
contractOperations: {
async execute() {
throw new Error("reference executor is not used by composition tests");
@@ -53,7 +55,7 @@ function installedInputs(
});
}
test("installs TechLog beside the retained reference feature through application-facing inputs", () => {
test("installs TechLog beside the retained reference feature through application-facing inputs", async () => {
const installed = installedInputs();
assert.deepEqual(Object.keys(installed), ["reference-feature", "tech-log"]);
@@ -66,7 +68,7 @@ test("installs TechLog beside the retained reference feature through application
assert.equal(Object.isFrozen(installed["tech-log"]), true);
assert.equal(Object.isFrozen(installed["tech-log"].publicContent), true);
assert.equal(
installed["tech-log"].publicContent.getRelease("0.1.0")?.title,
(await installed["tech-log"].publicContent.getRelease("0.1.0"))?.title,
"TechLog Public·Studio 경계를 확정했습니다",
);
});
@@ -109,9 +111,11 @@ test("each createStudioGateway call owns an isolated mutable Studio session", as
secondBefore.document.title,
);
assert.equal(
installed["tech-log"].publicContent.getRecord(
(
await installed["tech-log"].publicContent.getRecord(
"CASE",
"collection-fetch-join-pagination",
)
)?.title,
"컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
);
+70
View File
@@ -0,0 +1,70 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { MutationIntentProvider } from "../../src/presentation/adapters/query/mutation-intent-provider.tsx";
import { QueryInvalidationProvider } from "../../src/presentation/adapters/query/query-invalidation-provider.tsx";
import type { MutationIntentFactory } from "../../src/application/ports/mutation-intent-factory.ts";
import type { QueryInvalidationCoordinator } from "../../src/contracts/query-invalidation.ts";
/**
* The provider stack `useApplicationQuery` needs, in the order the running app
* assembles it (`ServerStateGenerationProvider`).
*
* A component test that renders a screen reading server state has to supply
* this or the render throws "No QueryClient set" — which is not a test-harness
* quirk but the same failure the app would produce if it were mounted without
* its query layer.
*/
export function renderWithQueryProviders(children: ReactNode): ReactNode {
const client = new QueryClient({
defaultOptions: {
// Deterministic: a component test asserts on one settled render, so a
// retry would only turn a real failure into a timeout.
queries: { retry: false, staleTime: 0, gcTime: Infinity },
mutations: { retry: false },
},
});
const coordinator: QueryInvalidationCoordinator = {
async invalidate(topics) {
for (const topic of topics) {
await client.invalidateQueries({
queryKey: [topic],
exact: false,
refetchType: "active",
});
}
},
beginMutation() {
return { release: async () => {} };
},
async resetLocal() {
await client.cancelQueries();
client.clear();
},
dispose() {},
};
let sequence = 0;
const mutationIntentFactory: MutationIntentFactory = Object.freeze({
create(input) {
sequence += 1;
return Object.freeze({
intentId: `intent-${sequence}`,
operationId: input.operationId,
canonicalInputIdentity: input.canonicalInputIdentity,
...(input.requiresIdempotencyKey
? { idempotencyKey: `key-${sequence}` }
: {}),
createdAtMonotonicMs: sequence,
});
},
});
return (
<MutationIntentProvider factory={mutationIntentFactory}>
<QueryClientProvider client={client}>
<QueryInvalidationProvider coordinator={coordinator}>
{children}
</QueryInvalidationProvider>
</QueryClientProvider>
</MutationIntentProvider>
);
}
+1
View File
@@ -15,6 +15,7 @@ import type { TechLogInstallContext } from "../../src/features/tech-log/adapters
*/
export const MOCK_STUDIO_INSTALL_CONTEXT: TechLogInstallContext = Object.freeze({
studioSource: "MOCK",
publicSource: "MOCK",
contractOperations: Object.freeze({
async execute() {
throw new Error("contract executor is not used by the mock Studio gateway");
@@ -47,6 +47,7 @@ const runtime: Parameters<typeof loadReleaseManifest>[0] = {
},
FEATURE_OVERRIDES: {},
TECH_LOG_STUDIO_SOURCE: "MOCK",
TECH_LOG_PUBLIC_SOURCE: "MOCK",
},
configSchema: "V2",
validationDurationMs: 0,
+1
View File
@@ -76,6 +76,7 @@ const runtimeV2 = {
},
FEATURE_OVERRIDES: {},
TECH_LOG_STUDIO_SOURCE: "MOCK",
TECH_LOG_PUBLIC_SOURCE: "MOCK",
} as const satisfies RuntimeConfigArtifact;
async function releaseV2With(
+1
View File
@@ -28,6 +28,7 @@ const runtime: Runtime = {
},
FEATURE_OVERRIDES: {},
TECH_LOG_STUDIO_SOURCE: "MOCK",
TECH_LOG_PUBLIC_SOURCE: "MOCK",
},
configSchema: "V2",
build: {