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({ export const publicContentQueries = Object.freeze({
listRecords, async listRecords(filters?: RecordFilters) {
getRecord, return listRecords(filters);
getProject, },
getRelease, async getRecord<K extends RecordKind>(kind: K, slug: string) {
getProjectRecords, return getRecord(kind, slug);
getProjectDecisions, },
getProjectActivity, async getProject(slug: string) {
getHomeFocusItems, return getProject(slug);
searchPublicContent, },
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; }) satisfies PublicContentQueries;
@@ -165,17 +165,31 @@ export type SearchablePublicEntity = {
* The application-facing boundary for the immutable source Public catalog. * The application-facing boundary for the immutable source Public catalog.
* Method signatures intentionally retain the source query argument and return shapes. * 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<{ export type PublicContentQueries = Readonly<{
listRecords(filters?: RecordFilters): PublicRecord[]; listRecords(filters?: RecordFilters): Promise<PublicRecord[]>;
getRecord<K extends RecordKind>( getRecord<K extends RecordKind>(
kind: K, kind: K,
slug: string, slug: string,
): Extract<PublicRecord, { kind: K }> | undefined; ): Promise<Extract<PublicRecord, { kind: K }> | undefined>;
getProject(slug: string): Project | undefined; getProject(slug: string): Promise<Project | undefined>;
getRelease(version: string): Release | undefined; getRelease(version: string): Promise<Release | undefined>;
getProjectRecords(projectSlug: string): PublicRecord[]; getProjectRecords(projectSlug: string): Promise<PublicRecord[]>;
getProjectDecisions(projectSlug: string): ProjectDecision[]; getProjectDecisions(projectSlug: string): Promise<ProjectDecision[]>;
getProjectActivity(projectSlug: string): ProjectActivity[]; getProjectActivity(projectSlug: string): Promise<ProjectActivity[]>;
getHomeFocusItems(): HomeFocusItem[]; getHomeFocusItems(): Promise<HomeFocusItem[]>;
searchPublicContent(query: string): SearchablePublicEntity[]; searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
}>; }>;
@@ -1,8 +1,7 @@
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import type { RecordKind } from "../../../application/ports/public-content-queries.ts"; import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; import { usePublicContent } from "../use-public-content.tsx";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
export function ExploreFilterForm({ export function ExploreFilterForm({
action, action,
@@ -18,20 +17,34 @@ export function ExploreFilterForm({
showType?: boolean; showType?: boolean;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); // This form sits inside a page that renders its own loading state, so it does
const publicRecords = publicContent.listRecords(); // not hand back a fallback of its own — that would put a second skeleton
const topics = [...new Set(publicRecords.map((record) => record.topic))].sort(); // inside a screen already showing one, and move the layout under it. It
const projectPrefix = "/projects/"; // renders its real structure immediately with empty option lists and fills
const projects = publicContent // them in when the catalog arrives.
.searchPublicContent("") const view = usePublicContent(["tech-log", "explore-filters"], async (queries) => {
.filter((entity) => entity.contentType === "PROJECT") const projectPrefix = "/projects/";
.flatMap((entity) => { const [records, entities] = await Promise.all([
if (!entity.path.startsWith(projectPrefix)) return []; queries.listRecords(),
const item = publicContent.getProject( queries.searchPublicContent(""),
decodeURIComponent(entity.path.slice(projectPrefix.length)), ]);
const projectSlugs = entities
.filter((entity) => entity.contentType === "PROJECT")
.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 normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
const selectedTopic = topics.find( const selectedTopic = topics.find(
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic, (item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
@@ -1,8 +1,7 @@
import { useId, useRef, useState } from "react"; import { useId, useRef, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; import { usePublicContent } from "../use-public-content.tsx";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
type SearchDialogProps = { type SearchDialogProps = {
className?: string; className?: string;
@@ -19,8 +18,21 @@ export function SearchDialog({
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLocaleLowerCase("ko-KR"); const normalizedQuery = query.trim().toLocaleLowerCase("ko-KR");
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); // Keyed on the empty query, then filtered here, rather than one request per
const results = publicContent.searchPublicContent(normalizedQuery); // 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() { function open() {
onBeforeOpen?.(); 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 { import {
RegisteredNotFoundRoute, RegisteredNotFoundRoute,
useRouteInput, useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx"; } from "../../../../../presentation/routes/route-input.tsx";
import { CaseDocumentPage } from "../components/case-document-page.tsx"; import { CaseDocumentPage } from "../components/case-document-page.tsx";
import { usePublicContent } from "../use-public-content.tsx";
function optionalString(value: unknown): string | undefined { function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined; return typeof value === "string" ? value : undefined;
@@ -14,9 +13,12 @@ export function CasePage() {
const { params, search } = useRouteInput<"TECH_LOG_CASE">(); const { params, search } = useRouteInput<"TECH_LOG_CASE">();
const slug = optionalString(params.slug); const slug = optionalString(params.slug);
const requestedState = optionalString(search.state); const requestedState = optionalString(search.state);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); const view = usePublicContent(["tech-log", "case", slug], async (queries) => ({
const record = slug ? publicContent.getRecord("CASE", slug) : undefined; record: slug ? await queries.getRecord("CASE", slug) : undefined,
}));
if (!view.ready) return view.fallback;
const { record } = view.data;
if (!record) return <RegisteredNotFoundRoute />; if (!record) return <RegisteredNotFoundRoute />;
return ( return (
@@ -1,13 +1,12 @@
import { Link } from "react-router-dom"; 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 { import {
RegisteredNotFoundRoute, RegisteredNotFoundRoute,
useRouteInput, useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx"; } from "../../../../../presentation/routes/route-input.tsx";
import { ExploreFilterForm } from "../components/explore-filter-form.tsx"; import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx"; import { PublicRecordList } from "../components/public-record-list.tsx";
import { usePublicContent } from "../use-public-content.tsx";
const kinds = { const kinds = {
cases: { kind: "CASE", title: "Case", description: "문제를 재현하고 관찰한 값에서 설계 결론까지 따라갑니다." }, cases: { kind: "CASE", title: "Case", description: "문제를 재현하고 관찰한 값에서 설계 결론까지 따라갑니다." },
@@ -28,18 +27,29 @@ function getKindConfig(value: string | undefined) {
export function ExploreKindPage() { export function ExploreKindPage() {
const { params, search } = useRouteInput<"TECH_LOG_EXPLORE_KIND">(); const { params, search } = useRouteInput<"TECH_LOG_EXPLORE_KIND">();
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
const kind = optionalString(params.kind); const kind = optionalString(params.kind);
const config = getKindConfig(kind); const config = getKindConfig(kind);
if (!config) return <RegisteredNotFoundRoute />;
const topic = optionalString(search.topic); const topic = optionalString(search.topic);
const project = optionalString(search.project); const project = optionalString(search.project);
const records = publicContent.listRecords({ // The unknown-kind check reads as an early return, but it cannot come before
kind: config.kind, // the query: hooks run unconditionally or React loses the call order. The
...(topic ? { topic } : {}), // loader short-circuits instead, and the not-found route is chosen below.
...(project ? { project } : {}), 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"> 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> <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} /> <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 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 { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { ExploreFilterForm } from "../components/explore-filter-form.tsx"; import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx"; import { PublicRecordList } from "../components/public-record-list.tsx";
import { usePublicContent } from "../use-public-content.tsx";
function optionalString(value: unknown): string | undefined { function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined; return typeof value === "string" ? value : undefined;
@@ -17,13 +16,19 @@ export function ExplorePage() {
const kind = (["CASE", "REFERENCE", "QUESTION"] as const).find( const kind = (["CASE", "REFERENCE", "QUESTION"] as const).find(
(item) => item === requestedKind, (item) => item === requestedKind,
) satisfies RecordKind | undefined; ) satisfies RecordKind | undefined;
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); const view = usePublicContent(
const records = publicContent.listRecords({ ["tech-log", "explore", kind, topic, project],
...(kind ? { kind } : {}), async (queries) => ({
...(topic ? { topic } : {}), records: await queries.listRecords({
...(project ? { project } : {}), ...(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"> 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> <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} /> <ExploreFilterForm action="/explore" kind={kind} topic={topic} project={project} />
@@ -1,11 +1,10 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { PublicContentQueries } from "../../../application/ports/public-content-queries.ts"; 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 { publicSiteConfig } from "../../../contracts/public-site-config.ts";
import { normalizeFocus } from "../../../domain/public/focus-state.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 { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { usePublicContent } from "../use-public-content.tsx";
import { FatalErrorState } from "../components/fatal-error-state.tsx"; import { FatalErrorState } from "../components/fatal-error-state.tsx";
import { HomeFocus } from "../components/home-focus.tsx"; import { HomeFocus } from "../components/home-focus.tsx";
import { import {
@@ -40,12 +39,14 @@ function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined; return typeof value === "string" ? value : undefined;
} }
function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] { async function getLatestEntries(
const publicRecords = publicContent.listRecords(); publicContent: PublicContentQueries,
): Promise<LatestEntry[]> {
const publicRecords = await publicContent.listRecords();
const publicRecordByPath = new Map( const publicRecordByPath = new Map(
publicRecords.map((record) => [record.path, record]), publicRecords.map((record) => [record.path, record]),
); );
const searchableEntities = publicContent.searchPublicContent(""); const searchableEntities = await publicContent.searchPublicContent("");
const projectPrefix = "/projects/"; const projectPrefix = "/projects/";
const projectSlugs = searchableEntities const projectSlugs = searchableEntities
.filter((entity) => entity.contentType === "PROJECT") .filter((entity) => entity.contentType === "PROJECT")
@@ -54,10 +55,16 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
? [decodeURIComponent(entity.path.slice(projectPrefix.length))] ? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
: [], : [],
); );
const projectTimeline = projectSlugs.flatMap((projectSlug) => { // One project at a time would serialise a request per project; issuing them
const project = publicContent.getProject(projectSlug); // together keeps the timeline's cost at its slowest project rather than their
if (!project) return []; // sum. The flatten below restores the original single-list shape.
return publicContent.getProjectActivity(projectSlug).map((activity) => { const projectTimeline = (
await Promise.all(
projectSlugs.map(async (projectSlug) => {
const project = await publicContent.getProject(projectSlug);
if (!project) return [];
const activities = await publicContent.getProjectActivity(projectSlug);
return activities.map((activity) => {
const record = publicRecordByPath.get( const record = publicRecordByPath.get(
activity.recordPath ?? activity.path, activity.recordPath ?? activity.path,
); );
@@ -77,20 +84,24 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
topic: record?.topic ?? project.topics[0] ?? "", topic: record?.topic ?? project.topics[0] ?? "",
project: project.title, project: project.title,
path: activity.path, path: activity.path,
}; };
}); });
}); }),
const releaseTimeline = searchableEntities )
.filter((entity) => entity.contentType === "RELEASE") ).flat();
.flatMap((entity) => { const releaseTimeline = (
const prefix = "/releases/"; await Promise.all(
if (!entity.path.startsWith(prefix)) return []; searchableEntities
const release = publicContent.getRelease( .filter((entity) => entity.contentType === "RELEASE")
decodeURIComponent(entity.path.slice(prefix.length)), .map(async (entity) => {
); const prefix = "/releases/";
if (!release) return []; if (!entity.path.startsWith(prefix)) return [];
return [ const release = await publicContent.getRelease(
{ decodeURIComponent(entity.path.slice(prefix.length)),
);
if (!release) return [];
return [
{
id: `release-${release.version}`, id: `release-${release.version}`,
typeLabel: "RELEASE", typeLabel: "RELEASE",
title: release.title, title: release.title,
@@ -99,10 +110,12 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
dateTime: release.publishedAt, dateTime: release.publishedAt,
topic: "TechLog", topic: "TechLog",
project: "TechLog", project: "TechLog",
path: release.path, path: release.path,
}, },
]; ];
}); }),
)
).flat();
return [...projectTimeline, ...releaseTimeline].sort((left, right) => return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
right.dateTime.localeCompare(left.dateTime), right.dateTime.localeCompare(left.dateTime),
@@ -113,8 +126,16 @@ export function HomePage() {
const { search } = useRouteInput<"TECH_LOG_HOME">(); const { search } = useRouteInput<"TECH_LOG_HOME">();
const requestedKey = optionalString(search.focus); const requestedKey = optionalString(search.focus);
const requestedState = optionalString(search.state); const requestedState = optionalString(search.state);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); const view = usePublicContent(["tech-log", "home"], async (queries) => {
const focusItems = publicContent.getHomeFocusItems(); 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 availableFocusItems = requestedState === "focus-empty" ? [] : focusItems;
const normalizedKey = normalizeFocus( const normalizedKey = normalizeFocus(
requestedKey, requestedKey,
@@ -131,8 +152,6 @@ export function HomePage() {
return <FatalErrorState traceId="PREVIEW-HOME-500" />; return <FatalErrorState traceId="PREVIEW-HOME-500" />;
} }
const latestEntries = getLatestEntries(publicContent);
return ( return (
<main id="main-content"> <main id="main-content">
<section className="shell home-identity" aria-labelledby="home-title"> <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 { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
import { publicSiteConfig } from "../../../contracts/public-site-config.ts"; import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
import { usePublicContent } from "../use-public-content.tsx";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx"; import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
const principles = [ const principles = [
@@ -26,12 +27,15 @@ const currentProjectSlugs = ["backend-skeleton", "auth-lab"] as const;
const topics = ["Backend Architecture", "JPA", "Authentication", "Redis"] as const; const topics = ["Backend Architecture", "JPA", "Authentication", "Redis"] as const;
export function ProfilePage() { export function ProfilePage() {
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); const view = usePublicContent(["tech-log", "profile"], async (queries) => {
const currentProjects = currentProjectSlugs.flatMap((slug) => { const resolved = await Promise.all(
const project = publicContent.getProject(slug); currentProjectSlugs.map((slug) => queries.getProject(slug)),
return project ? [project] : []; );
return { currentProjects: resolved.filter((project) => project !== undefined) };
}); });
if (!view.ready) return view.fallback;
const { currentProjects } = view.data;
return ( return (
<main id="main-content" className="shell profile-page"> <main id="main-content" className="shell profile-page">
<header className="profile-header"> <header className="profile-header">
@@ -1,22 +1,26 @@
import { Link } from "react-router-dom"; 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 { import {
RegisteredNotFoundRoute, RegisteredNotFoundRoute,
useRouteInput, useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx"; } from "../../../../../presentation/routes/route-input.tsx";
import { ProjectPageHeader } from "../components/project-page-header.tsx"; import { ProjectPageHeader } from "../components/project-page-header.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ProjectActivityPage() { export function ProjectActivityPage() {
const { params } = useRouteInput<"TECH_LOG_PROJECT_ACTIVITY">(); const { params } = useRouteInput<"TECH_LOG_PROJECT_ACTIVITY">();
const slug = typeof params.slug === "string" ? params.slug : ""; const slug = typeof params.slug === "string" ? params.slug : "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); const view = usePublicContent(["tech-log", "project", slug, "activity"], async (queries) => {
const project = publicContent.getProject(slug); 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 />; if (!project) return <RegisteredNotFoundRoute />;
const activity = publicContent.getProjectActivity(slug);
return ( return (
<main id="main-content" className="shell project-page"> <main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={`${project.title} 활동`} /> <ProjectPageHeader project={project} title={`${project.title} 활동`} />
@@ -1,22 +1,26 @@
import { Link } from "react-router-dom"; 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 { import {
RegisteredNotFoundRoute, RegisteredNotFoundRoute,
useRouteInput, useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx"; } from "../../../../../presentation/routes/route-input.tsx";
import { ProjectPageHeader } from "../components/project-page-header.tsx"; import { ProjectPageHeader } from "../components/project-page-header.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ProjectDecisionsPage() { export function ProjectDecisionsPage() {
const { params } = useRouteInput<"TECH_LOG_PROJECT_DECISIONS">(); const { params } = useRouteInput<"TECH_LOG_PROJECT_DECISIONS">();
const slug = typeof params.slug === "string" ? params.slug : ""; const slug = typeof params.slug === "string" ? params.slug : "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); const view = usePublicContent(["tech-log", "project", slug, "decisions"], async (queries) => {
const project = publicContent.getProject(slug); 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 />; if (!project) return <RegisteredNotFoundRoute />;
const decisions = publicContent.getProjectDecisions(slug);
return ( return (
<main id="main-content" className="shell project-page"> <main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={`${project.title} 결정`} /> <ProjectPageHeader project={project} title={`${project.title} 결정`} />
@@ -1,24 +1,34 @@
import { Link } from "react-router-dom"; 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 { import {
RegisteredNotFoundRoute, RegisteredNotFoundRoute,
useRouteInput, useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx"; } from "../../../../../presentation/routes/route-input.tsx";
import { ProjectPageHeader } from "../components/project-page-header.tsx"; import { ProjectPageHeader } from "../components/project-page-header.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ProjectOverviewPage() { export function ProjectOverviewPage() {
const { params } = useRouteInput<"TECH_LOG_PROJECT">(); const { params } = useRouteInput<"TECH_LOG_PROJECT">();
const slug = typeof params.slug === "string" ? params.slug : ""; const slug = typeof params.slug === "string" ? params.slug : "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); const view = usePublicContent(["tech-log", "project", slug, "overview"], async (queries) => {
const project = publicContent.getProject(slug); 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 />; if (!project) return <RegisteredNotFoundRoute />;
const records = publicContent.getProjectRecords(slug);
const decisions = publicContent.getProjectDecisions(slug);
const activity = publicContent.getProjectActivity(slug);
return ( return (
<main id="main-content" className="shell project-page"> <main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={project.title} /> <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 { import {
RegisteredNotFoundRoute, RegisteredNotFoundRoute,
useRouteInput, useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx"; } from "../../../../../presentation/routes/route-input.tsx";
import { ProjectPageHeader } from "../components/project-page-header.tsx"; import { ProjectPageHeader } from "../components/project-page-header.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx"; import { PublicRecordList } from "../components/public-record-list.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ProjectRecordsPage() { export function ProjectRecordsPage() {
const { params } = useRouteInput<"TECH_LOG_PROJECT_RECORDS">(); const { params } = useRouteInput<"TECH_LOG_PROJECT_RECORDS">();
const slug = typeof params.slug === "string" ? params.slug : ""; const slug = typeof params.slug === "string" ? params.slug : "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); const view = usePublicContent(["tech-log", "project", slug, "records"], async (queries) => {
const project = publicContent.getProject(slug); 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 />; if (!project) return <RegisteredNotFoundRoute />;
const records = publicContent.getProjectRecords(slug);
return ( return (
<main id="main-content" className="shell project-page"> <main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={`${project.title} 기록`} /> <ProjectPageHeader project={project} title={`${project.title} 기록`} />
@@ -1,18 +1,20 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; import { usePublicContent } from "../use-public-content.tsx";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
export function ProjectsPage() { export function ProjectsPage() {
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); const view = usePublicContent(["tech-log", "projects"], async (queries) => {
const projects = publicContent const entries = (await queries.searchPublicContent("")).filter(
.searchPublicContent("") (item) => item.contentType === "PROJECT",
.filter((item) => item.contentType === "PROJECT") );
.flatMap((item) => { const resolved = await Promise.all(
const project = publicContent.getProject(item.path.replace("/projects/", "")); entries.map((item) => queries.getProject(item.path.replace("/projects/", ""))),
return project ? [project] : []; );
}); return { projects: resolved.filter((project) => project !== undefined) };
});
if (!view.ready) return view.fallback;
const { projects } = view.data;
return ( return (
<main <main
id="main-content" 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 { import {
RegisteredNotFoundRoute, RegisteredNotFoundRoute,
useRouteInput, useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx"; } from "../../../../../presentation/routes/route-input.tsx";
import { QuestionDocumentPage } from "../components/question-document-page.tsx"; import { QuestionDocumentPage } from "../components/question-document-page.tsx";
import { usePublicContent } from "../use-public-content.tsx";
function optionalString(value: unknown): string | undefined { function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined; return typeof value === "string" ? value : undefined;
@@ -13,9 +12,15 @@ function optionalString(value: unknown): string | undefined {
export function QuestionPage() { export function QuestionPage() {
const { params } = useRouteInput<"TECH_LOG_QUESTION">(); const { params } = useRouteInput<"TECH_LOG_QUESTION">();
const slug = optionalString(params.slug); const slug = optionalString(params.slug);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); const view = usePublicContent(
const record = slug ? publicContent.getRecord("QUESTION", slug) : undefined; ["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 ? ( return record ? (
<QuestionDocumentPage record={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 { import {
RegisteredNotFoundRoute, RegisteredNotFoundRoute,
useRouteInput, useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx"; } from "../../../../../presentation/routes/route-input.tsx";
import { ReferenceDocumentPage } from "../components/reference-document-page.tsx"; import { ReferenceDocumentPage } from "../components/reference-document-page.tsx";
import { usePublicContent } from "../use-public-content.tsx";
function optionalString(value: unknown): string | undefined { function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined; return typeof value === "string" ? value : undefined;
@@ -13,9 +12,15 @@ function optionalString(value: unknown): string | undefined {
export function ReferencePage() { export function ReferencePage() {
const { params } = useRouteInput<"TECH_LOG_REFERENCE">(); const { params } = useRouteInput<"TECH_LOG_REFERENCE">();
const slug = optionalString(params.slug); const slug = optionalString(params.slug);
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); const view = usePublicContent(
const record = slug ? publicContent.getRecord("REFERENCE", slug) : undefined; ["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 ? ( return record ? (
<ReferenceDocumentPage record={record} /> <ReferenceDocumentPage record={record} />
) : ( ) : (
@@ -1,18 +1,20 @@
import { Link } from "react-router-dom"; 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 { import {
RegisteredNotFoundRoute, RegisteredNotFoundRoute,
useRouteInput, useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx"; } from "../../../../../presentation/routes/route-input.tsx";
import { usePublicContent } from "../use-public-content.tsx";
export function ReleasePage() { export function ReleasePage() {
const { params } = useRouteInput<"TECH_LOG_RELEASE">(); const { params } = useRouteInput<"TECH_LOG_RELEASE">();
const version = typeof params.version === "string" ? params.version : ""; const version = typeof params.version === "string" ? params.version : "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); const view = usePublicContent(["tech-log", "release", version], async (queries) => ({
const release = publicContent.getRelease(version); release: await queries.getRelease(version),
}));
if (!view.ready) return view.fallback;
const { release } = view.data;
if (!release) return <RegisteredNotFoundRoute />; if (!release) return <RegisteredNotFoundRoute />;
return ( return (
@@ -1,18 +1,20 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts"; import { usePublicContent } from "../use-public-content.tsx";
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
export function ReleasesPage() { export function ReleasesPage() {
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); const view = usePublicContent(["tech-log", "releases"], async (queries) => {
const releases = publicContent const entries = (await queries.searchPublicContent("")).filter(
.searchPublicContent("") (item) => item.contentType === "RELEASE",
.filter((item) => item.contentType === "RELEASE") );
.flatMap((item) => { const resolved = await Promise.all(
const release = publicContent.getRelease(item.path.replace("/releases/", "")); entries.map((item) => queries.getRelease(item.path.replace("/releases/", ""))),
return release ? [release] : []; );
}); return { releases: resolved.filter((release) => release !== undefined) };
});
if (!view.ready) return view.fallback;
const { releases } = view.data;
return ( return (
<main <main
id="main-content" id="main-content"
@@ -1,8 +1,7 @@
import { Link, useNavigate } from "react-router-dom"; 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 { 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; 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 navigate = useNavigate();
const { search } = useRouteInput<"TECH_LOG_SEARCH">(); const { search } = useRouteInput<"TECH_LOG_SEARCH">();
const query = optionalString(search.q)?.trim() ?? ""; const query = optionalString(search.q)?.trim() ?? "";
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID); const view = usePublicContent(["tech-log", "search", query], async (queries) => ({
const results = publicContent.searchPublicContent(query); results: await queries.searchPublicContent(query),
}));
function submit(event: React.FormEvent<HTMLFormElement>) { function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -25,6 +25,9 @@ export function SearchPage() {
void navigate(`/search?q=${encodeURIComponent(nextQuery)}`); 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"> 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> <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> <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 { import {
RegisteredNotFoundRoute, RegisteredNotFoundRoute,
useRouteInput, useRouteInput,
} from "../../../../../presentation/routes/route-input.tsx"; } from "../../../../../presentation/routes/route-input.tsx";
import { PublicRecordList } from "../components/public-record-list.tsx"; import { PublicRecordList } from "../components/public-record-list.tsx";
import { usePublicContent } from "../use-public-content.tsx";
const topics = { const topics = {
jpa: { jpa: {
@@ -37,11 +36,17 @@ function topicConfig(value: unknown) {
export function TopicPage() { export function TopicPage() {
const { params } = useRouteInput<"TECH_LOG_TOPIC">(); const { params } = useRouteInput<"TECH_LOG_TOPIC">();
const topic = topicConfig(params.slug); 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 (!topic) return <RegisteredNotFoundRoute />;
if (!view.ready) return view.fallback;
const records = publicContent.listRecords({ topic: topic.title }); const { records } = view.data;
return ( return (
<main id="main-content" className="shell public-index-page"> <main id="main-content" className="shell public-index-page">
<header className="public-page-header"> <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 { useCallback, useEffect, useState, type ReactNode } from "react";
import { usePublicContent } from "../public/use-public-content.tsx";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { useApplication } from "../../../../presentation/providers/application-provider.tsx"; 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.features.get(TECH_LOG_FEATURE_ID).createStudioAssetGateway(),
[application], [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( const resolvePublishedLabel = useCallback(
(path: string) => (path: string) => records?.find((record) => record.path === path)?.publishedLabel,
application.features [records],
.get(TECH_LOG_FEATURE_ID)
.publicContent.listRecords()
.find((record) => record.path === path)?.publishedLabel,
[application],
); );
useEffect(() => { useEffect(() => {
@@ -49,10 +49,12 @@ const { AppRouter } = await import("../../src/presentation/routes/app-router.tsx
const { ApplicationProvider } = await import( const { ApplicationProvider } = await import(
"../../src/presentation/providers/application-provider.tsx" "../../src/presentation/providers/application-provider.tsx"
); );
const { renderWithQueryProviders } = await import("../helpers/query-providers.tsx");
function renderAt(path: string, disabled: boolean) { function renderAt(path: string, disabled: boolean) {
window.history.pushState({}, "", path); window.history.pushState({}, "", path);
return render( return render(
renderWithQueryProviders(
<ApplicationProvider <ApplicationProvider
application={createTestApplication({ application={createTestApplication({
session: createAnonymousSessionAdapter(), session: createAnonymousSessionAdapter(),
@@ -68,6 +70,7 @@ function renderAt(path: string, disabled: boolean) {
> >
<AppRouter /> <AppRouter />
</ApplicationProvider>, </ApplicationProvider>,
),
); );
} }
+44 -35
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 { 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 { 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 { ApplicationProvider } from "../../src/presentation/providers/application-provider.tsx";
import { renderWithQueryProviders } from "../helpers/query-providers.tsx";
import { import {
AppRouter, AppRouter,
createGroupedRouteObjects, createGroupedRouteObjects,
@@ -123,14 +124,16 @@ function createSignedInSessionAdapter() {
function renderRouter(session = createAnonymousSessionAdapter()) { function renderRouter(session = createAnonymousSessionAdapter()) {
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT); const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT);
return render( return render(
<ApplicationProvider renderWithQueryProviders(
application={createTestApplication({ <ApplicationProvider
session, application={createTestApplication({
featureInputs: { [TECH_LOG_FEATURE_ID]: techLog.input }, session,
})} featureInputs: { [TECH_LOG_FEATURE_ID]: techLog.input },
> })}
<AppRouter /> >
</ApplicationProvider>, <AppRouter />
</ApplicationProvider>,
),
); );
} }
@@ -154,15 +157,17 @@ describe("generic application router", () => {
}); });
render( render(
<ApplicationProvider application={createTestApplication()}> renderWithQueryProviders(
<LocaleProvider> <ApplicationProvider application={createTestApplication()}>
<ThemeProvider> <LocaleProvider>
<SessionProvider> <ThemeProvider>
<RouterProvider router={router} /> <SessionProvider>
</SessionProvider> <RouterProvider router={router} />
</ThemeProvider> </SessionProvider>
</LocaleProvider> </ThemeProvider>
</ApplicationProvider>, </LocaleProvider>
</ApplicationProvider>,
),
); );
expect( expect(
@@ -278,15 +283,17 @@ describe("generic application router", () => {
}); });
render( render(
<ApplicationProvider application={createTestApplication()}> renderWithQueryProviders(
<LocaleProvider> <ApplicationProvider application={createTestApplication()}>
<ThemeProvider> <LocaleProvider>
<SessionProvider> <ThemeProvider>
<RouterProvider router={router} /> <SessionProvider>
</SessionProvider> <RouterProvider router={router} />
</ThemeProvider> </SessionProvider>
</LocaleProvider> </ThemeProvider>
</ApplicationProvider>, </LocaleProvider>
</ApplicationProvider>,
),
); );
expect(await screen.findByTestId("studio-layout")).toBeVisible(); expect(await screen.findByTestId("studio-layout")).toBeVisible();
@@ -325,15 +332,17 @@ describe("generic application router", () => {
}); });
render( render(
<ApplicationProvider application={createTestApplication()}> renderWithQueryProviders(
<LocaleProvider> <ApplicationProvider application={createTestApplication()}>
<ThemeProvider> <LocaleProvider>
<SessionProvider> <ThemeProvider>
<RouterProvider router={router} /> <SessionProvider>
</SessionProvider> <RouterProvider router={router} />
</ThemeProvider> </SessionProvider>
</LocaleProvider> </ThemeProvider>
</ApplicationProvider>, </LocaleProvider>
</ApplicationProvider>,
),
); );
expect( expect(
@@ -30,6 +30,7 @@ const runtime: Runtime = {
}, },
FEATURE_OVERRIDES: {}, FEATURE_OVERRIDES: {},
TECH_LOG_STUDIO_SOURCE: "MOCK", TECH_LOG_STUDIO_SOURCE: "MOCK",
TECH_LOG_PUBLIC_SOURCE: "MOCK",
}, },
configSchema: "V2", configSchema: "V2",
build: { build: {
@@ -35,9 +35,11 @@ function inputOf(document: WorkingCopy) {
function installedInputs( function installedInputs(
studioSource: "MOCK" | "HTTP" = "MOCK", studioSource: "MOCK" | "HTTP" = "MOCK",
publicSource: "MOCK" | "HTTP" = "MOCK",
): InstalledInputs { ): InstalledInputs {
return createInstalledFeatureInputs({ return createInstalledFeatureInputs({
studioSource, studioSource,
publicSource,
contractOperations: { contractOperations: {
async execute() { async execute() {
throw new Error("reference executor is not used by composition tests"); 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(); const installed = installedInputs();
assert.deepEqual(Object.keys(installed), ["reference-feature", "tech-log"]); 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"]), true);
assert.equal(Object.isFrozen(installed["tech-log"].publicContent), true); assert.equal(Object.isFrozen(installed["tech-log"].publicContent), true);
assert.equal( assert.equal(
installed["tech-log"].publicContent.getRelease("0.1.0")?.title, (await installed["tech-log"].publicContent.getRelease("0.1.0"))?.title,
"TechLog Public·Studio 경계를 확정했습니다", "TechLog Public·Studio 경계를 확정했습니다",
); );
}); });
@@ -109,9 +111,11 @@ test("each createStudioGateway call owns an isolated mutable Studio session", as
secondBefore.document.title, secondBefore.document.title,
); );
assert.equal( assert.equal(
installed["tech-log"].publicContent.getRecord( (
"CASE", await installed["tech-log"].publicContent.getRecord(
"collection-fetch-join-pagination", "CASE",
"collection-fetch-join-pagination",
)
)?.title, )?.title,
"컬렉션 Fetch Join과 페이징은 왜 충돌하는가", "컬렉션 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({ export const MOCK_STUDIO_INSTALL_CONTEXT: TechLogInstallContext = Object.freeze({
studioSource: "MOCK", studioSource: "MOCK",
publicSource: "MOCK",
contractOperations: Object.freeze({ contractOperations: Object.freeze({
async execute() { async execute() {
throw new Error("contract executor is not used by the mock Studio gateway"); 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: {}, FEATURE_OVERRIDES: {},
TECH_LOG_STUDIO_SOURCE: "MOCK", TECH_LOG_STUDIO_SOURCE: "MOCK",
TECH_LOG_PUBLIC_SOURCE: "MOCK",
}, },
configSchema: "V2", configSchema: "V2",
validationDurationMs: 0, validationDurationMs: 0,
+1
View File
@@ -76,6 +76,7 @@ const runtimeV2 = {
}, },
FEATURE_OVERRIDES: {}, FEATURE_OVERRIDES: {},
TECH_LOG_STUDIO_SOURCE: "MOCK", TECH_LOG_STUDIO_SOURCE: "MOCK",
TECH_LOG_PUBLIC_SOURCE: "MOCK",
} as const satisfies RuntimeConfigArtifact; } as const satisfies RuntimeConfigArtifact;
async function releaseV2With( async function releaseV2With(
+1
View File
@@ -28,6 +28,7 @@ const runtime: Runtime = {
}, },
FEATURE_OVERRIDES: {}, FEATURE_OVERRIDES: {},
TECH_LOG_STUDIO_SOURCE: "MOCK", TECH_LOG_STUDIO_SOURCE: "MOCK",
TECH_LOG_PUBLIC_SOURCE: "MOCK",
}, },
configSchema: "V2", configSchema: "V2",
build: { build: {