From 4566f2d7a82ca49308d59e3d03289ac123effeab Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 20 Aug 2026 16:53:51 +0900 Subject: [PATCH] refactor: make the public read port async so a network adapter can implement it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../tech-log/adapters/static/public-query.ts | 46 ++++++-- .../ports/public-content-queries.ts | 32 +++-- .../public/components/explore-filter-form.tsx | 43 ++++--- .../public/components/search-dialog.tsx | 20 +++- .../presentation/public/pages/case-page.tsx | 10 +- .../public/pages/explore-kind-page.tsx | 28 +++-- .../public/pages/explore-page.tsx | 21 ++-- .../presentation/public/pages/home-page.tsx | 81 ++++++++----- .../public/pages/profile-page.tsx | 12 +- .../public/pages/project-activity-page.tsx | 14 ++- .../public/pages/project-decisions-page.tsx | 14 ++- .../public/pages/project-overview-page.tsx | 24 ++-- .../public/pages/project-records-page.tsx | 14 ++- .../public/pages/projects-page.tsx | 22 ++-- .../public/pages/question-page.tsx | 13 ++- .../public/pages/reference-page.tsx | 13 ++- .../public/pages/release-page.tsx | 10 +- .../public/pages/releases-page.tsx | 22 ++-- .../presentation/public/pages/search-page.tsx | 11 +- .../presentation/public/pages/topic-page.tsx | 15 ++- .../public/use-public-content.tsx | 109 ++++++++++++++++++ .../presentation/studio/studio-shell.tsx | 21 +++- .../component/product-feature-switch.test.tsx | 3 + tests/component/router.test.tsx | 79 +++++++------ .../reference-runtime-composition.test.ts | 1 + .../tech-log/runtime-composition.test.ts | 14 ++- tests/helpers/query-providers.tsx | 70 +++++++++++ tests/helpers/studio-install-context.ts | 1 + tests/runtime-schema/release-manifest.test.ts | 1 + tests/unit/release-coherence.test.ts | 1 + tests/unit/runtime-adapters.test.ts | 1 + 31 files changed, 564 insertions(+), 202 deletions(-) create mode 100644 src/features/tech-log/presentation/public/use-public-content.tsx create mode 100644 tests/helpers/query-providers.tsx diff --git a/src/features/tech-log/adapters/static/public-query.ts b/src/features/tech-log/adapters/static/public-query.ts index d6866bc..ca6e423 100644 --- a/src/features/tech-log/adapters/static/public-query.ts +++ b/src/features/tech-log/adapters/static/public-query.ts @@ -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(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; diff --git a/src/features/tech-log/application/ports/public-content-queries.ts b/src/features/tech-log/application/ports/public-content-queries.ts index d6ca418..f133f93 100644 --- a/src/features/tech-log/application/ports/public-content-queries.ts +++ b/src/features/tech-log/application/ports/public-content-queries.ts @@ -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; getRecord( kind: K, slug: string, - ): Extract | 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 | undefined>; + getProject(slug: string): Promise; + getRelease(version: string): Promise; + getProjectRecords(projectSlug: string): Promise; + getProjectDecisions(projectSlug: string): Promise; + getProjectActivity(projectSlug: string): Promise; + getHomeFocusItems(): Promise; + searchPublicContent(query: string): Promise; }>; diff --git a/src/features/tech-log/presentation/public/components/explore-filter-form.tsx b/src/features/tech-log/presentation/public/components/explore-filter-form.tsx index 27e351a..52fe466 100644 --- a/src/features/tech-log/presentation/public/components/explore-filter-form.tsx +++ b/src/features/tech-log/presentation/public/components/explore-filter-form.tsx @@ -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(); - const projectPrefix = "/projects/"; - const projects = publicContent - .searchPublicContent("") - .filter((entity) => entity.contentType === "PROJECT") - .flatMap((entity) => { - if (!entity.path.startsWith(projectPrefix)) return []; - const item = publicContent.getProject( - decodeURIComponent(entity.path.slice(projectPrefix.length)), + // 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 [records, entities] = await Promise.all([ + queries.listRecords(), + queries.searchPublicContent(""), + ]); + 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 selectedTopic = topics.find( (item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic, diff --git a/src/features/tech-log/presentation/public/components/search-dialog.tsx b/src/features/tech-log/presentation/public/components/search-dialog.tsx index 56e1cfa..ee01220 100644 --- a/src/features/tech-log/presentation/public/components/search-dialog.tsx +++ b/src/features/tech-log/presentation/public/components/search-dialog.tsx @@ -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(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?.(); diff --git a/src/features/tech-log/presentation/public/pages/case-page.tsx b/src/features/tech-log/presentation/public/pages/case-page.tsx index d1d2cc4..6d622cc 100644 --- a/src/features/tech-log/presentation/public/pages/case-page.tsx +++ b/src/features/tech-log/presentation/public/pages/case-page.tsx @@ -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 ; return ( diff --git a/src/features/tech-log/presentation/public/pages/explore-kind-page.tsx b/src/features/tech-log/presentation/public/pages/explore-kind-page.tsx index a772ad9..980e2de 100644 --- a/src/features/tech-log/presentation/public/pages/explore-kind-page.tsx +++ b/src/features/tech-log/presentation/public/pages/explore-kind-page.tsx @@ -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 ; const topic = optionalString(search.topic); const project = optionalString(search.project); - const records = publicContent.listRecords({ - kind: config.kind, - ...(topic ? { topic } : {}), - ...(project ? { project } : {}), - }); + // 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 ; + if (!view.ready) return view.fallback; + const { records } = view.data; return

Explore

{config.title}

{config.description}

diff --git a/src/features/tech-log/presentation/public/pages/explore-page.tsx b/src/features/tech-log/presentation/public/pages/explore-page.tsx index a8cc91c..08de317 100644 --- a/src/features/tech-log/presentation/public/pages/explore-page.tsx +++ b/src/features/tech-log/presentation/public/pages/explore-page.tsx @@ -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({ - ...(kind ? { kind } : {}), - ...(topic ? { topic } : {}), - ...(project ? { project } : {}), - }); + 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

Explore

탐색

유형과 기술 주제, 프로젝트를 조합해 공개 기록을 찾습니다.

diff --git a/src/features/tech-log/presentation/public/pages/home-page.tsx b/src/features/tech-log/presentation/public/pages/home-page.tsx index e42a909..1c7194b 100644 --- a/src/features/tech-log/presentation/public/pages/home-page.tsx +++ b/src/features/tech-log/presentation/public/pages/home-page.tsx @@ -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 { + 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); - if (!project) return []; - return publicContent.getProjectActivity(projectSlug).map((activity) => { + // 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 []; + const activities = await publicContent.getProjectActivity(projectSlug); + return activities.map((activity) => { const record = publicRecordByPath.get( activity.recordPath ?? activity.path, ); @@ -77,20 +84,24 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] { topic: record?.topic ?? project.topics[0] ?? "", project: project.title, path: activity.path, - }; - }); - }); - const releaseTimeline = searchableEntities - .filter((entity) => entity.contentType === "RELEASE") - .flatMap((entity) => { - const prefix = "/releases/"; - if (!entity.path.startsWith(prefix)) return []; - const release = publicContent.getRelease( - decodeURIComponent(entity.path.slice(prefix.length)), - ); - if (!release) return []; - return [ - { + }; + }); + }), + ) + ).flat(); + const releaseTimeline = ( + await Promise.all( + searchableEntities + .filter((entity) => entity.contentType === "RELEASE") + .map(async (entity) => { + const prefix = "/releases/"; + if (!entity.path.startsWith(prefix)) return []; + const release = await publicContent.getRelease( + decodeURIComponent(entity.path.slice(prefix.length)), + ); + if (!release) return []; + return [ + { id: `release-${release.version}`, typeLabel: "RELEASE", title: release.title, @@ -99,10 +110,12 @@ function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] { dateTime: release.publishedAt, topic: "TechLog", project: "TechLog", - path: release.path, - }, - ]; - }); + 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 ; } - const latestEntries = getLatestEntries(publicContent); - return (
diff --git a/src/features/tech-log/presentation/public/pages/profile-page.tsx b/src/features/tech-log/presentation/public/pages/profile-page.tsx index 9a345f4..4947adf 100644 --- a/src/features/tech-log/presentation/public/pages/profile-page.tsx +++ b/src/features/tech-log/presentation/public/pages/profile-page.tsx @@ -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 (
diff --git a/src/features/tech-log/presentation/public/pages/project-activity-page.tsx b/src/features/tech-log/presentation/public/pages/project-activity-page.tsx index 421964f..e65f205 100644 --- a/src/features/tech-log/presentation/public/pages/project-activity-page.tsx +++ b/src/features/tech-log/presentation/public/pages/project-activity-page.tsx @@ -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 ; - const activity = publicContent.getProjectActivity(slug); return (
diff --git a/src/features/tech-log/presentation/public/pages/project-decisions-page.tsx b/src/features/tech-log/presentation/public/pages/project-decisions-page.tsx index 29f3873..1a57135 100644 --- a/src/features/tech-log/presentation/public/pages/project-decisions-page.tsx +++ b/src/features/tech-log/presentation/public/pages/project-decisions-page.tsx @@ -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 ; - const decisions = publicContent.getProjectDecisions(slug); return (
diff --git a/src/features/tech-log/presentation/public/pages/project-overview-page.tsx b/src/features/tech-log/presentation/public/pages/project-overview-page.tsx index d8ff59b..115fd6e 100644 --- a/src/features/tech-log/presentation/public/pages/project-overview-page.tsx +++ b/src/features/tech-log/presentation/public/pages/project-overview-page.tsx @@ -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 ; - const records = publicContent.getProjectRecords(slug); - const decisions = publicContent.getProjectDecisions(slug); - const activity = publicContent.getProjectActivity(slug); return (
diff --git a/src/features/tech-log/presentation/public/pages/project-records-page.tsx b/src/features/tech-log/presentation/public/pages/project-records-page.tsx index c3cec6e..61aa06e 100644 --- a/src/features/tech-log/presentation/public/pages/project-records-page.tsx +++ b/src/features/tech-log/presentation/public/pages/project-records-page.tsx @@ -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 ; - const records = publicContent.getProjectRecords(slug); return (
diff --git a/src/features/tech-log/presentation/public/pages/projects-page.tsx b/src/features/tech-log/presentation/public/pages/projects-page.tsx index 8e5cd32..b3ae107 100644 --- a/src/features/tech-log/presentation/public/pages/projects-page.tsx +++ b/src/features/tech-log/presentation/public/pages/projects-page.tsx @@ -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 (
(); 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 ? ( ) : ( diff --git a/src/features/tech-log/presentation/public/pages/reference-page.tsx b/src/features/tech-log/presentation/public/pages/reference-page.tsx index eb80a5d..03dd9d5 100644 --- a/src/features/tech-log/presentation/public/pages/reference-page.tsx +++ b/src/features/tech-log/presentation/public/pages/reference-page.tsx @@ -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 ? ( ) : ( diff --git a/src/features/tech-log/presentation/public/pages/release-page.tsx b/src/features/tech-log/presentation/public/pages/release-page.tsx index bbfa602..f6d45ba 100644 --- a/src/features/tech-log/presentation/public/pages/release-page.tsx +++ b/src/features/tech-log/presentation/public/pages/release-page.tsx @@ -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 ; return ( diff --git a/src/features/tech-log/presentation/public/pages/releases-page.tsx b/src/features/tech-log/presentation/public/pages/releases-page.tsx index a129c00..6818caf 100644 --- a/src/features/tech-log/presentation/public/pages/releases-page.tsx +++ b/src/features/tech-log/presentation/public/pages/releases-page.tsx @@ -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 (
(); 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) { 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

Search

검색

제목과 요약, 주제, 프로젝트를 함께 검색합니다.

diff --git a/src/features/tech-log/presentation/public/pages/topic-page.tsx b/src/features/tech-log/presentation/public/pages/topic-page.tsx index cdcbf21..aaa7434 100644 --- a/src/features/tech-log/presentation/public/pages/topic-page.tsx +++ b/src/features/tech-log/presentation/public/pages/topic-page.tsx @@ -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 ; + if (!view.ready) return view.fallback; - const records = publicContent.listRecords({ topic: topic.title }); + const { records } = view.data; return (
diff --git a/src/features/tech-log/presentation/public/use-public-content.tsx b/src/features/tech-log/presentation/public/use-public-content.tsx new file mode 100644 index 0000000..0c08c97 --- /dev/null +++ b/src/features/tech-log/presentation/public/use-public-content.tsx @@ -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 = + | 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( + queryKey: readonly unknown[], + load: (queries: PublicContentQueries) => Promise, +): PublicContentView { + 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( + 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 ? ( + void query.retry()} + /> + ) : ( + + ), + }); +} + +/** + * 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 { + return ( + typeof value === "object" && + value !== null && + typeof (value as { kind?: unknown }).kind === "string" && + typeof (value as { code?: unknown }).code === "string" + ); +} diff --git a/src/features/tech-log/presentation/studio/studio-shell.tsx b/src/features/tech-log/presentation/studio/studio-shell.tsx index 3001b1f..99d49c7 100644 --- a/src/features/tech-log/presentation/studio/studio-shell.tsx +++ b/src/features/tech-log/presentation/studio/studio-shell.tsx @@ -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(() => { diff --git a/tests/component/product-feature-switch.test.tsx b/tests/component/product-feature-switch.test.tsx index 187594f..49de899 100644 --- a/tests/component/product-feature-switch.test.tsx +++ b/tests/component/product-feature-switch.test.tsx @@ -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( , + ), ); } diff --git a/tests/component/router.test.tsx b/tests/component/router.test.tsx index 1d2e208..37d6aea 100644 --- a/tests/component/router.test.tsx +++ b/tests/component/router.test.tsx @@ -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,14 +124,16 @@ function createSignedInSessionAdapter() { function renderRouter(session = createAnonymousSessionAdapter()) { const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT); return render( - - - , + renderWithQueryProviders( + + + , + ), ); } @@ -154,15 +157,17 @@ describe("generic application router", () => { }); render( - - - - - - - - - , + renderWithQueryProviders( + + + + + + + + + , + ), ); expect( @@ -278,15 +283,17 @@ describe("generic application router", () => { }); render( - - - - - - - - - , + renderWithQueryProviders( + + + + + + + + + , + ), ); expect(await screen.findByTestId("studio-layout")).toBeVisible(); @@ -325,15 +332,17 @@ describe("generic application router", () => { }); render( - - - - - - - - - , + renderWithQueryProviders( + + + + + + + + + , + ), ); expect( diff --git a/tests/features/reference-feature/reference-runtime-composition.test.ts b/tests/features/reference-feature/reference-runtime-composition.test.ts index 490fd72..76de54e 100644 --- a/tests/features/reference-feature/reference-runtime-composition.test.ts +++ b/tests/features/reference-feature/reference-runtime-composition.test.ts @@ -30,6 +30,7 @@ const runtime: Runtime = { }, FEATURE_OVERRIDES: {}, TECH_LOG_STUDIO_SOURCE: "MOCK", + TECH_LOG_PUBLIC_SOURCE: "MOCK", }, configSchema: "V2", build: { diff --git a/tests/features/tech-log/runtime-composition.test.ts b/tests/features/tech-log/runtime-composition.test.ts index 41d4b8b..039441a 100644 --- a/tests/features/tech-log/runtime-composition.test.ts +++ b/tests/features/tech-log/runtime-composition.test.ts @@ -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( - "CASE", - "collection-fetch-join-pagination", + ( + await installed["tech-log"].publicContent.getRecord( + "CASE", + "collection-fetch-join-pagination", + ) )?.title, "컬렉션 Fetch Join과 페이징은 왜 충돌하는가", ); diff --git a/tests/helpers/query-providers.tsx b/tests/helpers/query-providers.tsx new file mode 100644 index 0000000..801e64c --- /dev/null +++ b/tests/helpers/query-providers.tsx @@ -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 ( + + + + {children} + + + + ); +} diff --git a/tests/helpers/studio-install-context.ts b/tests/helpers/studio-install-context.ts index 7e139cc..963d523 100644 --- a/tests/helpers/studio-install-context.ts +++ b/tests/helpers/studio-install-context.ts @@ -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"); diff --git a/tests/runtime-schema/release-manifest.test.ts b/tests/runtime-schema/release-manifest.test.ts index 750ef1e..ff2a1c1 100644 --- a/tests/runtime-schema/release-manifest.test.ts +++ b/tests/runtime-schema/release-manifest.test.ts @@ -47,6 +47,7 @@ const runtime: Parameters[0] = { }, FEATURE_OVERRIDES: {}, TECH_LOG_STUDIO_SOURCE: "MOCK", + TECH_LOG_PUBLIC_SOURCE: "MOCK", }, configSchema: "V2", validationDurationMs: 0, diff --git a/tests/unit/release-coherence.test.ts b/tests/unit/release-coherence.test.ts index 31957ce..984ffcd 100644 --- a/tests/unit/release-coherence.test.ts +++ b/tests/unit/release-coherence.test.ts @@ -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( diff --git a/tests/unit/runtime-adapters.test.ts b/tests/unit/runtime-adapters.test.ts index fbc4b17..14e07e4 100644 --- a/tests/unit/runtime-adapters.test.ts +++ b/tests/unit/runtime-adapters.test.ts @@ -28,6 +28,7 @@ const runtime: Runtime = { }, FEATURE_OVERRIDES: {}, TECH_LOG_STUDIO_SOURCE: "MOCK", + TECH_LOG_PUBLIC_SOURCE: "MOCK", }, configSchema: "V2", build: {