@timber-js/app 0.2.0-alpha.121 → 0.2.0-alpha.122
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/actions-Dy8vOjFt.js.map +1 -1
- package/dist/_chunks/{build-output-helper-B1n0sUqj.js → build-output-helper-DYOWsPjZ.js} +47 -6
- package/dist/_chunks/build-output-helper-DYOWsPjZ.js.map +1 -0
- package/dist/_chunks/cli-schema-sync-4HcQIziG.js +2 -0
- package/dist/_chunks/{cli-schema-sync-ByZPGGmS.js → cli-schema-sync-VcCf1TFY.js} +17 -3
- package/dist/_chunks/{cli-schema-sync-ByZPGGmS.js.map → cli-schema-sync-VcCf1TFY.js.map} +1 -1
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-DESG8uk5.js} +32 -19
- package/dist/_chunks/metadata-routes-DESG8uk5.js.map +1 -0
- package/dist/_chunks/{resolve-schema-Bf8eC6q6.js → resolve-schema-cURaYvaD.js} +5 -1
- package/dist/_chunks/resolve-schema-cURaYvaD.js.map +1 -0
- package/dist/_chunks/{segment-classify-CnfLLvir.js → segment-classify-BjfuctV2.js} +1 -1
- package/dist/_chunks/{segment-classify-CnfLLvir.js.map → segment-classify-BjfuctV2.js.map} +1 -1
- package/dist/_chunks/{walkers-DNp9tHyV.js → walkers-tkw7sDlj.js} +12 -9
- package/dist/_chunks/walkers-tkw7sDlj.js.map +1 -0
- package/dist/adapters/build-output-helper.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/cli-schema-sync.d.ts.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts +9 -5
- package/dist/client/link.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +101 -9
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +1 -1
- package/dist/params/resolve-schema.d.ts.map +1 -1
- package/dist/plugins/build-manifest.d.ts.map +1 -1
- package/dist/plugins/cloudflare-wasm.d.ts +18 -0
- package/dist/plugins/cloudflare-wasm.d.ts.map +1 -0
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/routing/codegen-shared.d.ts +5 -4
- package/dist/routing/codegen-shared.d.ts.map +1 -1
- package/dist/routing/index.js +3 -3
- package/dist/server/actions.d.ts +3 -8
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +6 -0
- package/dist/server/build-manifest.d.ts.map +1 -1
- package/dist/server/internal.d.ts +1 -1
- package/dist/server/internal.d.ts.map +1 -1
- package/dist/server/internal.js +13 -4
- package/dist/server/internal.js.map +1 -1
- package/dist/server/metadata-routes.d.ts +23 -7
- package/dist/server/metadata-routes.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +4 -2
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/tree-match.d.ts +12 -4
- package/dist/server/tree-match.d.ts.map +1 -1
- package/dist/server/types.d.ts +2 -2
- package/package.json +1 -1
- package/src/adapters/build-output-helper.ts +67 -5
- package/src/cli-schema-sync.ts +25 -4
- package/src/client/browser-entry/index.ts +1 -1
- package/src/client/link.tsx +9 -9
- package/src/index.ts +2 -0
- package/src/params/resolve-schema.ts +15 -0
- package/src/plugins/build-manifest.ts +57 -5
- package/src/plugins/cloudflare-wasm.ts +103 -0
- package/src/plugins/dev-server.ts +4 -2
- package/src/plugins/routing.ts +1 -1
- package/src/routing/codegen-shared.ts +8 -5
- package/src/routing/codegen.ts +5 -0
- package/src/routing/link-codegen.ts +1 -1
- package/src/server/actions.ts +4 -14
- package/src/server/build-manifest.ts +7 -0
- package/src/server/internal.ts +7 -1
- package/src/server/metadata-routes.ts +44 -19
- package/src/server/pipeline-phases.ts +8 -0
- package/src/server/request-context.ts +0 -4
- package/src/server/route-element-builder.ts +57 -11
- package/src/server/route-matcher.ts +81 -49
- package/src/server/rsc-entry/index.ts +7 -1
- package/src/server/rsc-entry/render-route.ts +7 -1
- package/src/server/ssr-entry.ts +6 -0
- package/src/server/tree-match.ts +23 -11
- package/src/server/types.ts +2 -2
- package/dist/_chunks/build-output-helper-B1n0sUqj.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-DHQLjFu3.js +0 -2
- package/dist/_chunks/metadata-routes-Cjmvi3rQ.js.map +0 -1
- package/dist/_chunks/resolve-schema-Bf8eC6q6.js.map +0 -1
- package/dist/_chunks/walkers-DNp9tHyV.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"walkers-tkw7sDlj.js","names":[],"sources":["../../src/routing/codegen-shared.ts","../../src/routing/link-codegen.ts","../../src/routing/codegen.ts","../../src/routing/interception.ts","../../src/routing/schema-validation.ts","../../src/routing/walkers.ts"],"sourcesContent":["/**\n * Shared codegen helpers — import-path computation, codec chain type\n * builder, and searchParams type formatter.\n *\n * Extracted from `codegen.ts` so `link-codegen.ts` can use the same\n * helpers without a cyclic import.\n */\n\nimport { relative, posix } from 'node:path';\nimport type { ParamEntry, RouteEntry } from './codegen-types.js';\n\n/**\n * Compute a relative import specifier for a codec/page file, stripping\n * the .ts/.tsx extension and resolving against the codegen output dir.\n */\nexport function codecImportPath(codecFilePath: string, importBase: string | undefined): string {\n const absPath = codecFilePath.replace(/\\.(ts|tsx)$/, '');\n if (importBase) {\n return './' + relative(importBase, absPath).replace(/\\\\/g, '/');\n }\n return './' + posix.basename(absPath);\n}\n\n/** Name of the shared helper type emitted at the top of the .d.ts. */\nexport const RESOLVE_SEGMENT_FIELD_TYPE_NAME = '_TimberResolveSegmentField';\n\n/**\n * Helper type emitted once at the top of the generated `.d.ts` and\n * referenced by every codec-chain conditional. Without this shared\n * helper, the inline expansion would duplicate the fallback branch on\n * every step and grow O(2^N) in chain depth (a single deep nested route\n * could blow up the file size and TS performance). With the helper,\n * each step reuses the named type and growth is O(N).\n *\n * The helper is a 2-arg conditional: given a typeof import expression\n * (`Def`), a key (`K`), and a fallback (`F`), it returns `T[K]` if\n * `Def extends ParamsDefinition<T>` and `K extends keyof T`, otherwise\n * `F`. The codec chain composes calls to this helper.\n */\nexport function emitResolveSegmentFieldHelper(): string {\n return [\n `type ${RESOLVE_SEGMENT_FIELD_TYPE_NAME}<Def, K extends string, F> =`,\n ` Def extends import('@timber-js/app/segment-params').ParamsDefinition<infer T>`,\n ` ? K extends keyof T ? T[K] : F`,\n ` : F;`,\n ].join('\\n');\n}\n\n/**\n * Build a TypeScript type expression that resolves a single param's\n * codec by walking a chain of params.ts files in priority order.\n *\n * Each entry in the chain emits one application of the shared\n * `_TimberResolveSegmentField` helper type. Composing N applications\n * grows linearly with chain depth (O(N) characters), unlike an inline\n * conditional that would duplicate the fallback in each branch and\n * grow O(2^N).\n *\n * The closest match (position 0 in the chain) is checked first; if its\n * `segmentParams` definition declares the key, its inferred type wins.\n * Otherwise we fall through to the next entry, and finally to the\n * provided fallback. See TIM-834.\n */\nexport function buildCodecChainType(\n p: ParamEntry,\n importBase: string | undefined,\n fallback: string\n): string {\n const files = p.codecFilePaths;\n if (!files || files.length === 0) return fallback;\n const key = JSON.stringify(p.name);\n // Compose helper applications inside-out so the closest entry\n // (files[0]) ends up as the OUTERMOST application. Each application\n // adds a constant-size wrapper around the running fallback.\n let inner = fallback;\n for (let i = files.length - 1; i >= 0; i--) {\n const importPath = codecImportPath(files[i], importBase);\n inner = `${RESOLVE_SEGMENT_FIELD_TYPE_NAME}<(typeof import('${importPath}'))['segmentParams'], ${key}, ${inner}>`;\n }\n return inner;\n}\n\n/**\n * Build a TypeScript type expression for a single param using the global\n * schema's InferCodecType. Falls back to the param's base type when\n * schema doesn't declare this param (schemas are opt-in per key).\n *\n * Generates a conditional that checks key existence:\n * `[bracketName] extends keyof typeof schema.segmentParams ? InferCodecType<...> : fallback`\n */\nexport function buildSchemaParamType(p: ParamEntry, fallback?: string): string {\n const key = JSON.stringify(p.bracketName);\n const fb = fallback ?? p.type;\n return `${key} extends keyof typeof schema['segmentParams'] ? InferCodecType<typeof schema.segmentParams[${key}]> : ${fb}`;\n}\n\n/**\n * Format the searchParams type for a route entry.\n *\n * When a page.tsx (or params.ts) exports searchParams, we reference its\n * inferred type via an import type. The import path is relative to\n * `importBase` (the directory where the .d.ts will be written). When\n * importBase is undefined, falls back to a bare relative path.\n */\nexport function formatSearchParamsType(route: RouteEntry, importBase?: string): string {\n if (route.hasSearchParams && route.searchParamsPagePath) {\n const importPath = codecImportPath(route.searchParamsPagePath, importBase);\n // Extract the type from the named 'searchParams' export of the page module.\n return `(typeof import('${importPath}'))['searchParams'] extends import('@timber-js/app/search-params').SearchParamsDefinition<infer T> ? T : never`;\n }\n return '{}';\n}\n","/**\n * Typed `<Link>` codegen — interface augmentation generation.\n *\n * Extracted from `codegen.ts` to keep that file under the project's\n * 500-line cap. This module owns everything that emits the\n * `interface LinkFunction { ... }` augmentation blocks in the generated\n * `.timber/timber-routes.d.ts`.\n *\n * Two augmentation blocks are emitted:\n *\n * 1. **Per-route discriminated union** (`formatTypedLinkOverloads`) —\n * one call signature whose props is a union keyed on `href`. TS\n * narrows by literal href and reports prop errors against the\n * matched variant. See TIM-835 and `design/09-typescript.md`.\n *\n * 2. **Catch-all overloads** (`formatLinkCatchAllOverloads`) — external\n * href literals (`http://`, `mailto:`, etc.) and a computed-string\n * `<H extends string>` signature for runtime-computed paths.\n *\n * Block ordering is critical for error UX: per-route is emitted FIRST\n * so that, after TS's \"later overload set ordered first\" merge rule,\n * the discriminated union ends up LAST in resolution order — the\n * overload TS reports against on failure.\n */\n\nimport type { ParamEntry, RouteEntry } from './codegen-types.js';\nimport {\n buildCodecChainType,\n buildSchemaParamType,\n formatSearchParamsType,\n} from './codegen-shared.js';\n\n/** Shared Link base-props type literal used in every emitted call signature. */\nexport const LINK_BASE_PROPS_TYPE =\n \"Omit<import('react').AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> & { prefetch?: boolean; scroll?: boolean; preserveSearchParams?: true | string[]; onNavigate?: import('./client/link.js').OnNavigateHandler; children?: import('react').ReactNode }\";\n\n/**\n * Build a TypeScript template literal pattern for a dynamic route.\n * e.g. '/products/[id]' → '/products/${string}'\n * '/blog/[...slug]' → '/blog/${string}'\n * '/docs/[[...path]]' → '/docs/${string}' (also matches /docs)\n * '/[org]/[repo]' → '/${string}/${string}'\n */\nexport function buildResolvedPattern(route: RouteEntry): string | null {\n const parts = route.urlPath.split('/');\n const templateParts = parts.map((part) => {\n if (part.startsWith('[[...') && part.endsWith(']]')) {\n // Optional catch-all — matches any trailing path\n return '${string}';\n }\n if (part.startsWith('[...') && part.endsWith(']')) {\n // Catch-all — matches one or more segments\n return '${string}';\n }\n if (part.startsWith('[') && part.endsWith(']')) {\n // Dynamic segment\n return '${string}';\n }\n return part;\n });\n return templateParts.join('/');\n}\n\n/**\n * Format the segmentParams type for Link overloads.\n *\n * Link params accept `string | number` for single dynamic segments\n * (convenience — values are stringified at runtime). Catch-all and\n * optional catch-all remain `string[]` / `string[] | undefined`.\n *\n * When the segment's params chain (TIM-834) declares a typed codec, the\n * inferred type from the codec wins via a nested conditional.\n */\nexport function formatLinkParamsType(\n params: ParamEntry[],\n importBase?: string,\n hasSchema?: boolean\n): string {\n if (params.length === 0) {\n return '{}';\n }\n\n const fields = params.map((p) => {\n const fallback = p.type === 'string' ? 'string | number' : p.type;\n const codecType = hasSchema\n ? buildSchemaParamType(p, fallback)\n : buildCodecChainType(p, importBase, fallback);\n return `${p.name}: ${codecType}`;\n });\n return `{ ${fields.join('; ')} }`;\n}\n\n/**\n * Catch-all call signatures for `<Link>` — external hrefs and computed\n * `string` variables. Emitted from codegen in a SEPARATE\n * `declare module` block (declared AFTER the per-route block) so the TS\n * \"later overload set ordered first\" rule places these catch-all\n * signatures ahead of per-route in resolution order, leaving per-route\n * as the final overload whose error message is reported on failure.\n *\n * The conditional `string extends H ? ... : never` protection preserves\n * TIM-624's guarantee that unknown internal path literals don't match\n * the catch-all — typos like `<Link href=\"/typo\" />` still error.\n */\nexport function formatLinkCatchAllOverloads(): string[] {\n const lines: string[] = [];\n const baseProps = LINK_BASE_PROPS_TYPE;\n\n // TIM-830: the catch-all signatures accept EITHER the legacy\n // `{ definition, values }` wrapper OR a flat `Record<string, unknown>`\n // values object. External/computed hrefs can't be looked up in the\n // runtime registry, so the wrapped form is still the reliable path;\n // the flat form is kept permissive for callers migrating from typed-\n // route hrefs to computed strings. `resolveHref` discriminates at\n // runtime via the presence of a `definition` key.\n const catchAllSearchParams =\n '{ definition: SearchParamsDefinition<Record<string, unknown>>; values: Record<string, unknown> } | Record<string, unknown>';\n\n // ExternalHref inlined here rather than referenced as an exported\n // alias so the generated .d.ts stands alone without source imports.\n const externalHref =\n '`http://${string}` | `https://${string}` | `mailto:${string}` | `tel:${string}` | `ftp://${string}` | `//${string}` | `#${string}` | `?${string}`';\n\n lines.push(' // Typed Link overloads — catch-all (block 2 / emitted second)');\n lines.push(' interface LinkFunction {');\n\n // (1) External/literal-protocol hrefs.\n //\n // TIM-833: `segmentParams` is permissively typed as\n // `Record<string, unknown>` (not `never`) so generic wrappers that\n // forward a `LinkProps`-shaped object can compile when the wrapper\n // happens to bottom out at an external href. There are no dynamic\n // segments to interpolate for an external href, so this prop is\n // ignored at runtime — accepting an open-shape object instead of\n // forbidding it removes a footgun without changing behavior.\n lines.push(` (props: ${baseProps} & {`);\n lines.push(` href: ${externalHref}`);\n lines.push(` segmentParams?: Record<string, unknown>`);\n lines.push(` searchParams?: ${catchAllSearchParams}`);\n lines.push(` }): import('react').JSX.Element`);\n\n // (2) Computed/variable href — non-literal `string` only.\n //\n // `string extends H` is true only when H is the wide `string` type,\n // not a specific literal. For literal hrefs, this overload must NOT\n // be selectable so the per-route discriminated union (block 1) is\n // the resolution target.\n //\n // TIM-833 follow-up: the previous shape used `string extends H ? {...} : never`\n // on the WHOLE props type. For literal H, that collapsed `props` to\n // `never`, and JSX type inference then read `(never).children` as\n // `never` — producing the misleading\n // `'children' prop expects type 'never' which requires multiple\n // children, but only a single child was provided` (TS2745) error,\n // which buried the actual prop-mismatch message under a noise diagnostic.\n //\n // Fix: move the `: never` from the whole props onto just the `href`\n // field. The overload remains uncallable for literal H (since `\"/x\"`\n // is not assignable to `never`), but `children` retains its real\n // `ReactNode` type so JSX inference no longer collapses to never.\n // The diagnostic surface becomes: TS reports the per-route block's\n // error chain (the helpful \"Type 'number' is not assignable to type\n // 'string'\" message) WITHOUT a leading TS2745 noise line.\n lines.push(` <H extends string>(`);\n lines.push(` props: ${baseProps} & {`);\n lines.push(` href: string extends H ? H : never`);\n lines.push(` segmentParams?: Record<string, string | number | string[]>`);\n lines.push(` searchParams?: ${catchAllSearchParams}`);\n lines.push(` }`);\n lines.push(` ): import('react').JSX.Element`);\n\n lines.push(' }');\n return lines;\n}\n\n/**\n * Generate typed per-route Link call signatures via LinkFunction\n * interface merging.\n *\n * TIM-835: This emits a SINGLE call signature whose props is a\n * discriminated union keyed on `href`. TypeScript narrows the union by\n * the literal `href` at the call site, then checks the rest of the\n * props against the matched variant. When `segmentParams` or\n * `searchParams` is wrong, TS reports the error against the matched\n * variant — naming the user's actual `href` and the offending field.\n *\n * Before TIM-835, this function emitted N separate per-route overloads\n * (one per route, sometimes two for dynamic routes). When ALL overloads\n * failed, TS would pick an arbitrary failed overload (heuristically the\n * \"last tried\") to render the diagnostic, often pointing at a route\n * completely unrelated to the one the user wrote. The discriminated\n * union sidesteps overload-resolution heuristics entirely.\n *\n * Open property shapes (`Record<string, unknown>` instead of `never`)\n * for `segmentParams` on routes that don't declare them keep generic\n * `LinkProps`-spreading wrappers compiling. (Aligned with TIM-833.)\n *\n * TIM-832: this function still emits the per-route block FIRST and the\n * catch-all block follows, so per-route remains the LAST overload set in\n * resolution order — the one TS reports against on failure. With a\n * discriminated union there is only one call signature in this block,\n * so the resolved-template-vs-pattern ordering reduces to placing the\n * pattern variant before the resolved-template variant inside the union.\n */\nexport function formatTypedLinkOverloads(\n routes: RouteEntry[],\n importBase?: string,\n hasSchema?: boolean\n): string[] {\n const lines: string[] = [];\n const baseProps = LINK_BASE_PROPS_TYPE;\n\n // Build the union variants. Each route contributes one variant for the\n // pattern href (e.g. '/products/[id]') and, for dynamic routes, an\n // additional variant for the resolved-template href (e.g.\n // `/products/${string}`).\n //\n // The PATTERN variant must be listed BEFORE the resolved-template\n // variant for the same route so that when the user writes the literal\n // pattern href (`<Link href=\"/products/[id]\" />`), TS narrows to the\n // pattern variant (which carries the typed `segmentParams` shape)\n // rather than the looser resolved-template variant. Without this\n // ordering, TS would silently match the resolved-template variant\n // first — swallowing typed-segmentParams type errors.\n const variants: string[] = [];\n for (const route of routes) {\n const hasDynamicParams = route.params.length > 0;\n // For routes with no dynamic params, accept an absent OR open-shape\n // segmentParams. `Record<string, unknown>` keeps generic spreads\n // compiling and gives a readable error if a non-object is passed.\n const paramsProp = hasDynamicParams\n ? `segmentParams: ${formatLinkParamsType(route.params, importBase, hasSchema)}`\n : 'segmentParams?: Record<string, unknown>';\n\n const searchParamsType = route.hasSearchParams\n ? formatSearchParamsType(route, importBase)\n : null;\n // TIM-830: pattern href uses the FLAT `Partial<T>` shape — the\n // runtime registry is keyed by the un-interpolated pattern.\n //\n // TIM-833: when the route has NO searchParams definition, use\n // `Record<string, unknown>` (open shape) instead of\n // `Record<string, never>` so generic `LinkProps`-spreading wrappers\n // compile. The trade-off vs the original TIM-835 strict shape is\n // that excess searchParams keys on a no-def route no longer raise\n // a type error — but those keys are runtime no-ops anyway, and\n // wrapper compatibility is the more common pain point. Routes WITH\n // a searchParams definition still get strict `Partial<T>` typing.\n const patternSearchParamsProp = searchParamsType\n ? `searchParams?: Partial<${searchParamsType}>`\n : 'searchParams?: Record<string, unknown>';\n\n // Pattern variant FIRST (more specific).\n variants.push(\n `${baseProps} & { href: '${route.urlPath}'; ${paramsProp}; ${patternSearchParamsProp} }`\n );\n\n // Resolved-template variant SECOND (looser, matches interpolated hrefs).\n if (hasDynamicParams) {\n const templatePattern = buildResolvedPattern(route);\n if (templatePattern) {\n // TIM-830: resolved-template href keeps the WRAPPED\n // `{ definition, values }` shape — the registry can't be looked\n // up by an already-interpolated href.\n //\n // TIM-833: searchParams falls back to `Record<string, unknown>`\n // (was `Record<string, never>`) when the route has no\n // definition, mirroring the pattern-variant change above. The\n // resolved-template `segmentParams` is intentionally KEPT as\n // `Record<string, never>` below to preserve TIM-835's\n // pattern-variant typo detection: loosening segmentParams here\n // would let the resolved-template variant shadow the pattern\n // variant on literal pattern hrefs and silently swallow keyed\n // typos like `<Link href=\"/[id]\" segmentParams={{ idz: 1 }} />`.\n const resolvedSearchParamsProp = searchParamsType\n ? `searchParams?: { definition: SearchParamsDefinition<${searchParamsType}>; values: Partial<${searchParamsType}> }`\n : 'searchParams?: Record<string, unknown>';\n // TIM-835: `Record<string, never>` for segmentParams forbids ANY\n // provided keys on the resolved-template variant. Without this,\n // the resolved-template would shadow the pattern variant for\n // literal pattern hrefs (`<Link href=\"/products/[id]\" segmentParams={...} />`)\n // because both variants accept the literal `'/products/[id]'`\n // and the looser variant would silently swallow segmentParams\n // type errors.\n variants.push(\n `${baseProps} & { href: \\`${templatePattern}\\`; segmentParams?: Record<string, never>; ${resolvedSearchParamsProp} }`\n );\n }\n }\n }\n\n lines.push(' interface LinkFunction {');\n if (variants.length === 0) {\n // No page routes — emit nothing. The catch-all block in the second\n // augmentation still provides external/computed-string signatures.\n lines.push(' }');\n return lines;\n }\n lines.push(' (');\n lines.push(' props:');\n for (const variant of variants) {\n lines.push(` | (${variant})`);\n }\n lines.push(` ): import('react').JSX.Element`);\n lines.push(' }');\n\n return lines;\n}\n","/**\n * Route map codegen.\n *\n * Walks the scanned RouteTree and generates a TypeScript declaration file\n * mapping every route to its params and searchParams shapes.\n *\n * This runs at build time and in dev (regenerated on file changes).\n * No runtime overhead — purely static type generation.\n */\n\nimport { existsSync, readFileSync } from 'node:fs';\nimport { join, relative } from 'node:path';\nimport type { RouteTree, SegmentNode } from './types.js';\nimport type { ParamEntry, RouteEntry } from './codegen-types.js';\nimport {\n buildCodecChainType,\n buildSchemaParamType,\n emitResolveSegmentFieldHelper,\n formatSearchParamsType,\n} from './codegen-shared.js';\nimport { formatLinkCatchAllOverloads, formatTypedLinkOverloads } from './link-codegen.js';\n\n/** Options for route map generation. */\nexport interface CodegenOptions {\n /** Absolute path to the app/ directory. Required for page searchParams detection. */\n appDir?: string;\n /**\n * Absolute path to the directory where the .d.ts file will be written.\n * Used to compute correct relative import paths for page files.\n * Defaults to appDir when not provided (preserves backward compat for tests).\n */\n outputDir?: string;\n /**\n * Whether a global schema (app/schema.ts) exists.\n * Auto-detected from appDir when not explicitly set.\n */\n hasSchema?: boolean;\n}\n\n/**\n * Generate a TypeScript declaration file string from a scanned route tree.\n *\n * The output is a `declare module '@timber-js/app'` block containing the Routes\n * interface that maps every route path to its params and searchParams shape.\n */\nexport function generateRouteMap(tree: RouteTree, options: CodegenOptions = {}): string {\n // Auto-detect schema from appDir when not explicitly set\n const hasSchema = options.hasSchema ?? detectSchema(options.appDir);\n\n const routes: RouteEntry[] = [];\n collectRoutes(tree.root, [], [], '', routes, hasSchema);\n\n // Sort routes alphabetically for deterministic output\n routes.sort((a, b) => a.urlPath.localeCompare(b.urlPath));\n\n // When outputDir differs from appDir, import paths must be relative to outputDir\n const importBase = options.outputDir ?? options.appDir;\n\n return formatDeclarationFile(routes, importBase, hasSchema, options.appDir);\n}\n\n/** Schema file extensions to check, in priority order. */\nconst SCHEMA_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];\n\n/**\n * Detect whether app/schema.{ts,tsx,js,jsx} exists.\n */\nfunction detectSchema(appDir: string | undefined): boolean {\n if (!appDir) return false;\n return SCHEMA_EXTENSIONS.some((ext) => existsSync(join(appDir, `schema${ext}`)));\n}\n\n/**\n * Recursively walk the segment tree and collect route entries.\n *\n * A route entry is created for any segment that has a `page` or `route` file.\n * Params accumulate from ancestor dynamic segments.\n */\nfunction collectRoutes(\n node: SegmentNode,\n ancestorParams: ParamEntry[],\n ancestorParamsFiles: string[],\n parentTreePath: string,\n routes: RouteEntry[],\n hasSchema: boolean\n): void {\n // Build the tree path (includes groups, slots — uniquely identifies this segment).\n // Root node has empty segmentName, producing treePath = ''.\n const treePath = node.segmentName ? `${parentTreePath}/${node.segmentName}` : parentTreePath;\n // TIM-834: Identify this segment's own params.ts (if it has a\n // segmentParams export). The full chain of params.ts files in the\n // route ancestry is threaded down via `ancestorParamsFiles`; codec\n // resolution for each ParamEntry is deferred until leaf time so that\n // descendant params.ts files can override ancestor codecs (closest-\n // to-leaf wins, matching the runtime semantics of\n // coerceSegmentParams which walks segments top-down and overwrites\n // earlier coercions).\n const ownParamsFile =\n node.params && fileHasExport(node.params.filePath, 'segmentParams')\n ? node.params.filePath\n : undefined;\n\n // Accumulate params from this segment. We attach `codecFilePaths`\n // later (at leaf time) using the FULL chain so descendant overrides\n // are visible. The legacy layout/page fallback is recorded now\n // because it is per-segment (and does not participate in inheritance).\n const params = [...ancestorParams];\n if (node.paramName) {\n const legacyFallback = ownParamsFile ? undefined : findLegacyParamsExport(node);\n params.push({\n name: node.paramName,\n bracketName: node.segmentName,\n type: paramTypeForSegment(node.segmentType),\n // Codec chain populated at leaf time. We carry the per-segment\n // legacy fallback (if any) so leaf-time resolution can fall back\n // to it when no params.ts in the chain declares this key.\n legacyCodecFilePath: legacyFallback,\n });\n }\n\n // Extend the chain for descendants of this segment.\n const nextAncestorFiles = ownParamsFile\n ? [...ancestorParamsFiles, ownParamsFile]\n : ancestorParamsFiles;\n\n // Check if this segment is a leaf route (has page or route file)\n const isPage = !!node.page;\n const isApiRoute = !!node.route;\n\n if (isPage || isApiRoute) {\n // TIM-834 P1 fix: at LEAF time, the full chain of params.ts files\n // (root-to-leaf) is known. Resolve every ParamEntry's\n // `codecFilePaths` to the chain in LEAF-FIRST order so the\n // closest-to-leaf entry is checked first — matching runtime\n // closest-wins semantics. The chain is shared by all params in the\n // route, so we compute it once.\n const leafFirstChain = nextAncestorFiles.length > 0 ? [...nextAncestorFiles].reverse() : [];\n const resolvedParams: ParamEntry[] = params.map((p) => {\n const codecFilePaths =\n leafFirstChain.length > 0\n ? leafFirstChain\n : p.legacyCodecFilePath\n ? [p.legacyCodecFilePath]\n : undefined;\n return {\n name: p.name,\n bracketName: p.bracketName,\n type: p.type,\n codecFilePaths,\n };\n });\n\n const entry: RouteEntry = {\n urlPath: node.urlPath,\n segmentPath: treePath || '/',\n params: resolvedParams,\n hasSearchParams: false,\n isApiRoute,\n hasSchema,\n };\n\n // Detect searchParams export from params.ts (primary) or page.tsx (fallback)\n if (isPage) {\n if (node.params && fileHasExport(node.params.filePath, 'searchParams')) {\n entry.hasSearchParams = true;\n entry.searchParamsPagePath = node.params.filePath;\n } else if (node.page && fileHasExport(node.page.filePath, 'searchParams')) {\n entry.hasSearchParams = true;\n entry.searchParamsPagePath = node.page.filePath;\n }\n }\n\n routes.push(entry);\n }\n\n // Recurse into children\n for (const child of node.children) {\n collectRoutes(child, params, nextAncestorFiles, treePath, routes, hasSchema);\n }\n\n // Recurse into slots (they share the parent's URL path, but may have their own pages)\n for (const slot of Object.values(node.slots)) {\n collectRoutes(slot, params, nextAncestorFiles, treePath, routes, hasSchema);\n }\n}\n\n/**\n * Determine the TypeScript type for a segment's param.\n */\nfunction paramTypeForSegment(segmentType: string): ParamEntry['type'] {\n switch (segmentType) {\n case 'catch-all':\n return 'string[]';\n case 'optional-catch-all':\n return 'string[] | undefined';\n default:\n return 'string';\n }\n}\n\n/**\n * Check if a file exports a specific named export.\n *\n * Uses a lightweight regex check — not full AST parsing. The actual type\n * extraction happens via the TypeScript compiler in the generated .d.ts.\n */\nfunction fileHasExport(filePath: string, exportName: string): boolean {\n if (!existsSync(filePath)) return false;\n try {\n const content = readFileSync(filePath, 'utf-8');\n const e = exportName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n return (\n new RegExp(`export\\\\s+(const|let|var)\\\\s+${e}\\\\b`).test(content) ||\n new RegExp(`export\\\\s*\\\\{[^}]*\\\\b${e}\\\\b[^}]*\\\\}`).test(content)\n );\n } catch {\n return false;\n }\n}\n\n/**\n * Find a legacy `segmentParams` export on layout.tsx or page.tsx.\n *\n * Backward-compat shim: TIM-508 made params.ts the canonical location\n * for `segmentParams`. Layout/page exports are still accepted for the\n * OWN segment only (not inherited by descendants — see TIM-834).\n */\nfunction findLegacyParamsExport(node: SegmentNode): string | undefined {\n if (node.layout && fileHasExport(node.layout.filePath, 'segmentParams')) {\n return node.layout.filePath;\n }\n if (node.page && fileHasExport(node.page.filePath, 'segmentParams')) {\n return node.page.filePath;\n }\n return undefined;\n}\n\n/**\n * Emit the schema-based type exports: TimberSegmentParams, TimberRoute,\n * AllSegmentParams, RoutesWithParams.\n *\n * These types use InferCodecType references so they stay in sync with\n * the schema without hardcoding resolved types.\n *\n * Design doc: design/41-global-params.md §Codegen Output\n */\nfunction formatSchemaTypes(routes: RouteEntry[]): string[] {\n const lines: string[] = [];\n\n lines.push('/** Union of all valid route patterns. */');\n lines.push('export type TimberRoute = keyof TimberSegmentParams;');\n lines.push('');\n lines.push('/** Maps each segment path to its accumulated segment params. */');\n lines.push('export interface TimberSegmentParams {');\n\n for (const route of routes) {\n if (route.params.length === 0) {\n lines.push(` '${route.segmentPath}': {};`);\n } else {\n lines.push(` '${route.segmentPath}': {`);\n for (const p of route.params) {\n lines.push(` ${p.name}: ${buildSchemaParamType(p)};`);\n }\n lines.push(' };');\n }\n }\n\n lines.push('}');\n lines.push('');\n\n // AllSegmentParams — flat union of all param types across all routes\n lines.push('/** All segment params (union of all route params). */');\n lines.push('export type AllSegmentParams = {');\n lines.push(\n ' [K in keyof TimberSegmentParams as TimberSegmentParams[K] extends Record<string, never>'\n );\n lines.push(' ? never');\n lines.push(' : K]: TimberSegmentParams[K];');\n lines.push('} extends infer U');\n lines.push(' ? { [K in keyof U[keyof U]]: U[keyof U][K] }');\n lines.push(' : never;');\n lines.push('');\n\n // RoutesWithParams — filter routes by required param keys\n lines.push(\n '/** Filters TimberRoute to routes whose accumulated params include all listed keys. */'\n );\n lines.push('export type RoutesWithParams<K extends keyof AllSegmentParams> = {');\n lines.push(' [R in TimberRoute]: K extends keyof TimberSegmentParams[R] ? R : never;');\n lines.push('}[TimberRoute];');\n\n return lines;\n}\n\n/**\n * Format the collected routes into a TypeScript declaration file.\n */\nfunction formatDeclarationFile(\n routes: RouteEntry[],\n importBase?: string,\n hasSchema?: boolean,\n appDir?: string\n): string {\n const lines: string[] = [];\n\n lines.push('// This file is auto-generated by timber.js route map codegen.');\n lines.push('// Do not edit manually. Regenerated on build and in dev mode.');\n lines.push('');\n // export {} makes this file a module, so all declare module blocks are\n // augmentations rather than ambient replacements. Without this, the\n // declare module blocks would replace the original module types entirely\n // (removing exports like bindUseQueryStates that aren't listed here).\n lines.push('export {};');\n lines.push('');\n\n if (hasSchema) {\n // Schema-based codegen: import schema and InferCodecType\n // Compute the schema import path relative to the output directory\n const schemaImport =\n appDir && importBase\n ? './' + relative(importBase, join(appDir, 'schema')).replace(/\\\\/g, '/')\n : '../app/schema';\n lines.push(`import type schema from '${schemaImport}';`);\n lines.push(\"import type { InferCodecType, SegmentParamCodec } from '@timber-js/app/params';\");\n lines.push('');\n // Collect all unique bracket keys from routes for key validation\n const allBracketKeys = new Set<string>();\n for (const route of routes) {\n for (const p of route.params) {\n allBracketKeys.add(p.bracketName);\n }\n }\n\n // Augment the ValidDynamicSegmentKeysRegistry so defineSchema constrains keys\n // to known filesystem segments. Unknown keys produce inline TS errors.\n if (allBracketKeys.size > 0) {\n lines.push(\"declare module '@timber-js/app/params' {\");\n lines.push(' interface ValidDynamicSegmentKeysRegistry {');\n for (const key of [...allBracketKeys].sort()) {\n lines.push(` ${JSON.stringify(key)}: true;`);\n }\n lines.push(' }');\n lines.push('}');\n lines.push('');\n }\n // Type-level validation: each schema value must be a Codec or Standard Schema.\n // Running `pnpm run typecheck` will surface errors for invalid values.\n lines.push('type _AssertSchemaValues = {');\n lines.push(\n \" [K in keyof typeof schema['segmentParams']]: typeof schema['segmentParams'][K] extends SegmentParamCodec ? true : ['Error: value for', K, 'is not a valid Codec or Standard Schema'];\"\n );\n lines.push('};');\n // Generic constraint enforces that all values are `true` — if any value is\n // an error tuple, TS reports a real diagnostic instead of silently computing it.\n lines.push(\n 'type _EnforceSchemaValues<_T extends Record<string, true> = _AssertSchemaValues> = true;'\n );\n lines.push('');\n }\n\n if (!hasSchema) {\n // TIM-834 P2: emit the shared codec-resolution helper type ONCE so the\n // per-param chain conditionals reference it instead of inlining the\n // fallback in both branches (which grows O(2^N) in chain depth).\n lines.push(emitResolveSegmentFieldHelper());\n lines.push('');\n }\n\n lines.push(\"declare module '@timber-js/app' {\");\n lines.push(' interface Routes {');\n\n for (const route of routes) {\n const paramsType = formatParamsType(route.params, importBase, hasSchema);\n const searchParamsType = formatSearchParamsType(route, importBase);\n\n lines.push(` '${route.urlPath}': {`);\n lines.push(` segmentParams: ${paramsType}`);\n lines.push(` searchParams: ${searchParamsType}`);\n lines.push(` }`);\n }\n\n lines.push(' }');\n\n // When a global schema exists, emit segment param types inside the\n // @timber-js/app module augmentation so that declare module blocks\n // for @timber-js/app/server and @timber-js/app/client can reference\n // AllSegmentParams via `import type { AllSegmentParams } from '@timber-js/app'`.\n if (hasSchema) {\n lines.push('');\n lines.push(...formatSchemaTypes(routes).map((l) => ` ${l}`));\n }\n\n lines.push('}');\n lines.push('');\n\n const pageRoutes = routes.filter((r) => !r.isApiRoute);\n const dynamicRoutes = routes.filter((r) => r.params.length > 0);\n\n // Generate @timber-js/app/server augmentation — typed getSegmentParams\n if (dynamicRoutes.length > 0) {\n lines.push(\"declare module '@timber-js/app/server' {\");\n if (hasSchema) {\n lines.push(\" import type { AllSegmentParams } from '@timber-js/app'\");\n }\n for (const route of dynamicRoutes) {\n const paramsType = formatParamsType(route.params, importBase, hasSchema);\n lines.push(\n ` export function getSegmentParams(segmentPath: '${route.segmentPath}'): ${paramsType}`\n );\n }\n lines.push(\n hasSchema\n ? ' export function getSegmentParams(): Partial<AllSegmentParams>'\n : ' export function getSegmentParams(): Record<string, string | string[]>'\n );\n lines.push('}');\n lines.push('');\n }\n\n // Generate overloads for @timber-js/app/client\n\n if (dynamicRoutes.length > 0 || pageRoutes.length > 0) {\n lines.push(\"declare module '@timber-js/app/client' {\");\n lines.push(\n \" import type { SearchParamsDefinition, SetParams, QueryStatesOptions, SearchParamCodec } from '@timber-js/app/search-params'\"\n );\n if (hasSchema) {\n lines.push(\" import type { AllSegmentParams } from '@timber-js/app'\");\n }\n lines.push('');\n\n // useSegmentParams overloads\n if (dynamicRoutes.length > 0) {\n for (const route of dynamicRoutes) {\n const paramsType = formatParamsType(route.params, importBase, hasSchema);\n lines.push(\n ` export function useSegmentParams(segmentPath: '${route.segmentPath}'): ${paramsType}`\n );\n }\n lines.push(\n hasSchema\n ? ' export function useSegmentParams(): Partial<AllSegmentParams>'\n : ' export function useSegmentParams(): Record<string, string | string[]>'\n );\n lines.push('');\n }\n\n // useQueryStates overloads\n if (pageRoutes.length > 0) {\n lines.push(...formatUseQueryStatesOverloads(pageRoutes, importBase));\n lines.push('');\n }\n\n // Typed Link overloads — per-route with DIRECT types (no conditionals).\n // Direct types preserve TypeScript's excess property checking.\n //\n // TIM-832: per-route and catch-all are emitted as TWO separate\n // augmentation blocks. Per TS's merging rule \"later overload sets\n // ordered first\", the catch-all block (declared SECOND in this file)\n // ends up FIRST in the merged call-signature list at resolution time,\n // which puts the per-route block LAST — so its error message is the\n // one TypeScript reports when no overload matches. This gives users a\n // clear prop-mismatch error (e.g. \"'string | undefined' is not\n // assignable to 'string | number' on id\") instead of the old\n // confusing \"Type 'string' is not assignable to type 'never'\" cascade.\n if (pageRoutes.length > 0) {\n lines.push(' // Typed Link overloads — per-route (block 1 / emitted first)');\n lines.push(...formatTypedLinkOverloads(pageRoutes, importBase, hasSchema));\n lines.push('');\n }\n\n lines.push('}');\n lines.push('');\n }\n\n // TIM-832: catch-all block — emitted as a SEPARATE `declare module`\n // augmentation so TS's \"later overload set first\" rule orders it ahead\n // of the per-route block above at resolution time, leaving per-route as\n // the \"last overload\" whose error TypeScript reports.\n lines.push(\"declare module '@timber-js/app/client' {\");\n lines.push(\" import type { SearchParamsDefinition } from '@timber-js/app/search-params'\");\n lines.push('');\n lines.push(...formatLinkCatchAllOverloads());\n lines.push('}');\n lines.push('');\n\n return lines.join('\\n');\n}\n\n/**\n * Format the params type for a route entry.\n */\nfunction formatParamsType(params: ParamEntry[], importBase?: string, hasSchema?: boolean): string {\n if (params.length === 0) {\n return '{}';\n }\n\n const fields = params.map((p) => {\n const codecType = hasSchema\n ? buildSchemaParamType(p)\n : buildCodecChainType(p, importBase, p.type);\n return `${p.name}: ${codecType}`;\n });\n return `{ ${fields.join('; ')} }`;\n}\n\n/**\n * Generate useQueryStates overloads.\n *\n * For each page route:\n * - Routes with search-params.ts get a typed overload returning the inferred T\n * - Routes without search-params.ts get an overload returning [{}, SetParams<{}>]\n *\n * A fallback overload for standalone codecs (existing API) is emitted last.\n */\nfunction formatUseQueryStatesOverloads(routes: RouteEntry[], importBase?: string): string[] {\n const lines: string[] = [];\n\n for (const route of routes) {\n const searchParamsType = route.hasSearchParams\n ? formatSearchParamsType(route, importBase)\n : '{}';\n lines.push(\n ` export function useQueryStates<R extends '${route.urlPath}'>(route: R, options?: QueryStatesOptions): [${searchParamsType}, SetParams<${searchParamsType}>]`\n );\n }\n\n // Fallback: standalone codecs (existing API)\n lines.push(\n ' export function useQueryStates<T extends Record<string, unknown>>(codecs: { [K in keyof T]: SearchParamCodec<T[K]> }, options?: QueryStatesOptions): [T, SetParams<T>]'\n );\n\n return lines;\n}\n\n// Link overload formatters and helpers (`formatTypedLinkOverloads`,\n// `formatLinkCatchAllOverloads`, `formatLinkParamsType`,\n// `buildResolvedPattern`, `LINK_BASE_PROPS_TYPE`) were extracted to\n// `./link-codegen.ts` (TIM-835) to keep this file under the 500-line\n// cap. They are imported at the top of this file.\n","/**\n * Intercepting route utilities.\n *\n * Computes rewrite rules from the route tree that enable intercepting routes\n * to conditionally render when navigating via client-side (soft) navigation.\n *\n * The mechanism: at build time, each intercepting route directory generates a\n * conditional rewrite. On soft navigation, the client sends an `X-Timber-URL`\n * header with the current pathname. The server checks if any rewrite's source\n * (the intercepted URL) matches the target pathname AND the header matches\n * the intercepting route's parent URL. If both match, the intercepting route\n * renders instead of the normal route.\n *\n * On hard navigation (no header), no rewrite matches, and the normal route\n * renders.\n *\n * See design/07-routing.md §\"Intercepting Routes\"\n */\n\nimport type { SegmentNode, InterceptionMarker } from './types.js';\n\n/** A conditional rewrite rule generated from an intercepting route. */\nexport interface InterceptionRewrite {\n /**\n * The URL pattern that this rewrite intercepts (the target of navigation).\n * E.g., \"/photo/[id]\" for a (.)photo/[id] interception.\n */\n interceptedPattern: string;\n /**\n * The URL prefix that the client must be navigating FROM for this rewrite\n * to apply. Matched against the X-Timber-URL header.\n * E.g., \"/feed\" for a (.)photo/[id] inside /feed/@modal/.\n */\n interceptingPrefix: string;\n /**\n * Segments chain from root → intercepting leaf. Used to build the element\n * tree when the interception is active.\n */\n segmentPath: SegmentNode[];\n}\n\n/**\n * Collect all interception rewrite rules from the route tree.\n *\n * Walks the tree recursively. For each intercepting segment, computes the\n * intercepted URL based on the marker and the segment's position.\n */\nexport function collectInterceptionRewrites(root: SegmentNode): InterceptionRewrite[] {\n const rewrites: InterceptionRewrite[] = [];\n walkForInterceptions(root, [root], rewrites);\n return rewrites;\n}\n\n/**\n * Recursively walk the segment tree to find intercepting routes.\n */\nfunction walkForInterceptions(\n node: SegmentNode,\n ancestors: SegmentNode[],\n rewrites: InterceptionRewrite[]\n): void {\n // Check children\n for (const child of node.children) {\n if (child.segmentType === 'intercepting' && child.interceptionMarker) {\n // Found an intercepting route — collect rewrites from its sub-tree\n collectFromInterceptingNode(child, ancestors, rewrites);\n } else {\n walkForInterceptions(child, [...ancestors, child], rewrites);\n }\n }\n\n // Check slots (intercepting routes are typically inside slots like @modal)\n for (const slot of Object.values(node.slots)) {\n walkForInterceptions(slot, ancestors, rewrites);\n }\n}\n\n/**\n * For an intercepting segment, find all leaf pages in its sub-tree and\n * generate rewrite rules for each.\n */\nfunction collectFromInterceptingNode(\n interceptingNode: SegmentNode,\n ancestors: SegmentNode[],\n rewrites: InterceptionRewrite[]\n): void {\n const marker = interceptingNode.interceptionMarker!;\n const segmentName = interceptingNode.interceptedSegmentName!;\n\n // Compute the intercepted URL base based on the marker\n const parentUrlPath = ancestors[ancestors.length - 1].urlPath;\n const interceptedBase = computeInterceptedBase(parentUrlPath, marker);\n const interceptedUrlBase =\n interceptedBase === '/' ? `/${segmentName}` : `${interceptedBase}/${segmentName}`;\n\n // Find all leaf pages in the intercepting sub-tree\n collectLeavesWithRewrites(\n interceptingNode,\n interceptedUrlBase,\n parentUrlPath,\n [...ancestors, interceptingNode],\n rewrites\n );\n}\n\n/**\n * Recursively find leaf pages in an intercepting sub-tree and generate\n * rewrite rules for each.\n */\nfunction collectLeavesWithRewrites(\n node: SegmentNode,\n interceptedUrlPath: string,\n interceptingPrefix: string,\n segmentPath: SegmentNode[],\n rewrites: InterceptionRewrite[]\n): void {\n if (node.page) {\n rewrites.push({\n interceptedPattern: interceptedUrlPath,\n interceptingPrefix,\n segmentPath: [...segmentPath],\n });\n }\n\n for (const child of node.children) {\n const childUrl =\n child.segmentType === 'group'\n ? interceptedUrlPath\n : `${interceptedUrlPath}/${child.segmentName}`;\n collectLeavesWithRewrites(\n child,\n childUrl,\n interceptingPrefix,\n [...segmentPath, child],\n rewrites\n );\n }\n}\n\n/**\n * Compute the base URL that an intercepting route intercepts, given the\n * parent's URL path and the interception marker.\n *\n * - (.) — same level: parent's URL path\n * - (..) — one level up: parent's parent URL path\n * - (...) — root level: /\n * - (..)(..) — two levels up: parent's grandparent URL path\n *\n * Level counting operates on URL path segments, NOT filesystem directories.\n * Route groups and parallel slots are already excluded from urlPath (they\n * don't add URL depth), so (..) correctly climbs visible segments. This\n * avoids the Vinext bug where path.dirname() on filesystem paths would\n * waste climbs on invisible route groups.\n */\nfunction computeInterceptedBase(parentUrlPath: string, marker: InterceptionMarker): string {\n switch (marker) {\n case '(.)':\n return parentUrlPath;\n case '(..)': {\n const parts = parentUrlPath.split('/').filter(Boolean);\n parts.pop();\n return parts.length === 0 ? '/' : `/${parts.join('/')}`;\n }\n case '(...)':\n return '/';\n case '(..)(..)': {\n const parts = parentUrlPath.split('/').filter(Boolean);\n parts.pop();\n parts.pop();\n return parts.length === 0 ? '/' : `/${parts.join('/')}`;\n }\n }\n}\n","/**\n * Schema key/value validation against the filesystem route tree.\n *\n * Runs at codegen time (build + dev) to catch mismatches between\n * app/schema.ts keys and the actual dynamic segments on disk.\n *\n * Design doc: design/41-global-params.md\n */\n\nimport { readFileSync } from 'node:fs';\nimport type { RouteTree, SegmentNode } from './types.js';\nimport { findSchemaFile, parseExistingSchemaKeys } from '../cli-schema-sync.js';\nimport { classifyUrlSegment } from './segment-classify.js';\n\nexport interface SchemaWarning {\n type: 'stale-key' | 'missing-key' | 'bracket-mismatch';\n key: string;\n message: string;\n filesystemKey?: string;\n}\n\n/**\n * Collect all unique bracket-keyed dynamic segment names from a route tree.\n * Returns entries like '[artistSlug]', '[...slug]', '[[...topic]]'.\n */\nexport function collectDynamicSegmentsFromTree(tree: RouteTree): Set<string> {\n const segments = new Set<string>();\n walkTree(tree.root, segments);\n return segments;\n}\n\nfunction walkTree(node: SegmentNode, segments: Set<string>): void {\n if (node.paramName && node.segmentName) {\n const classified = classifyUrlSegment(node.segmentName);\n if (classified.kind !== 'static') {\n segments.add(node.segmentName);\n }\n }\n for (const child of node.children) {\n walkTree(child, segments);\n }\n for (const slot of Object.values(node.slots)) {\n walkTree(slot, segments);\n }\n}\n\n/**\n * Extract the param name from a bracket key.\n * '[id]' → 'id', '[...slug]' → 'slug', '[[...topic]]' → 'topic'\n */\nfunction extractParamName(bracketKey: string): string | null {\n const seg = classifyUrlSegment(bracketKey);\n if (seg.kind === 'static') return null;\n return seg.name;\n}\n\n/**\n * Validate schema keys against the filesystem route tree.\n *\n * Returns warnings for:\n * - Stale keys: schema keys that don't match any filesystem segment\n * - Bracket form mismatches: same param name, different bracket form\n * - Missing keys: filesystem segments not registered in schema (informational)\n */\nexport function validateSchemaAgainstRoutes(tree: RouteTree, appDir: string): SchemaWarning[] {\n const schemaPath = findSchemaFile(appDir);\n if (!schemaPath) return [];\n\n const content = readFileSync(schemaPath, 'utf-8');\n const schemaKeys = parseExistingSchemaKeys(content);\n\n if (schemaKeys.length === 0) return [];\n\n const filesystemSegments = collectDynamicSegmentsFromTree(tree);\n\n return validateKeys(schemaKeys, filesystemSegments);\n}\n\n/**\n * Core validation: compare schema keys against filesystem segments.\n * Exported separately for testing without filesystem dependencies.\n */\nexport function validateKeys(\n schemaKeys: string[],\n filesystemSegments: Set<string>\n): SchemaWarning[] {\n const warnings: SchemaWarning[] = [];\n\n // Build a map of param name → bracket form for filesystem segments\n const fsParamToKey = new Map<string, string>();\n for (const fsKey of filesystemSegments) {\n const name = extractParamName(fsKey);\n if (name) {\n fsParamToKey.set(name, fsKey);\n }\n }\n\n // Build a set of schema param names for the missing-key check\n const schemaParamNames = new Set<string>();\n\n for (const schemaKey of schemaKeys) {\n const schemaParamName = extractParamName(schemaKey);\n if (!schemaParamName) continue;\n\n schemaParamNames.add(schemaParamName);\n\n if (filesystemSegments.has(schemaKey)) {\n // Exact match — no issue\n continue;\n }\n\n // Check if the param name exists but with a different bracket form\n const fsKey = fsParamToKey.get(schemaParamName);\n if (fsKey) {\n warnings.push({\n type: 'bracket-mismatch',\n key: schemaKey,\n filesystemKey: fsKey,\n message:\n `Schema key '${schemaKey}' does not match filesystem bracket form '${fsKey}'. ` +\n `Update the schema key to '${fsKey}'.`,\n });\n } else {\n warnings.push({\n type: 'stale-key',\n key: schemaKey,\n message:\n `Schema key '${schemaKey}' does not match any dynamic segment in the filesystem. ` +\n `Remove it from app/schema.ts or create the corresponding route segment.`,\n });\n }\n }\n\n // Check for filesystem segments not in schema (informational)\n for (const fsKey of filesystemSegments) {\n const name = extractParamName(fsKey);\n if (name && !schemaParamNames.has(name)) {\n warnings.push({\n type: 'missing-key',\n key: fsKey,\n message:\n `Dynamic segment '${fsKey}' has no codec in app/schema.ts (defaults to string). ` +\n `Run: timber schema sync`,\n });\n }\n }\n\n return warnings;\n}\n","/**\n * Shared route-tree walkers (TIM-848).\n *\n * Tiny helpers that walk a `SegmentNode<TFile>` tree generically. Both the\n * build-time tree (`SegmentNode<RouteFile>`) and the runtime manifest\n * tree (`SegmentNode<ManifestFile>`) flow through these helpers because\n * the walker only reads the structural fields shared by both shapes\n * (`children`, `slots`, `page`, `route`, `urlPath`).\n *\n * Before this module, three near-identical `collectRoutes` functions\n * lived in `plugins/dev-404-page.ts`, `plugins/build-report.ts`, and\n * `routing/codegen.ts`. The codegen one is special-purpose (it\n * accumulates `ParamEntry[]` and resolves codec chains) and stays\n * local; the other two now share `collectLeafRoutes` from this file.\n */\n\nimport type { SegmentNode } from './types.js';\n\n/** A leaf route discovered while walking the segment tree. */\nexport interface LeafRoute<TFile> {\n /** URL path of the leaf (root is \"/\"). */\n urlPath: string;\n /** Segment chain from root to this leaf, inclusive. */\n segments: SegmentNode<TFile>[];\n /** The page file at this leaf, if any. */\n page?: TFile;\n /** The route handler file at this leaf, if any. */\n route?: TFile;\n}\n\n/** Options for `collectLeafRoutes`. */\nexport interface CollectLeafRoutesOptions {\n /**\n * If true, recurse into parallel slots and emit slot leaves alongside\n * the main route tree. Defaults to `false` because slots render\n * alongside their parent at the same URL and are not separately\n * URL-addressable. The build report excludes slots; route-listing\n * UIs that want to show \"all leaves with a page handler\" can opt in.\n */\n includeSlots?: boolean;\n}\n\n/**\n * Walk a segment tree and collect every leaf with a `page` or `route`\n * handler. Generic over `TFile` so it works on both the build-time\n * scanner output and the runtime manifest tree.\n *\n * - Pages and route handlers at the same URL produce two distinct\n * entries (the build report deduplicates by URL afterward).\n * - Parallel slots are skipped unless `includeSlots: true` (slots\n * share their parent's URL and are not addressable on their own).\n * - Result is sorted by `urlPath` for deterministic output.\n */\nexport function collectLeafRoutes<TFile>(\n root: SegmentNode<TFile>,\n options: CollectLeafRoutesOptions = {}\n): LeafRoute<TFile>[] {\n const { includeSlots = false } = options;\n const result: LeafRoute<TFile>[] = [];\n walk(root, [], result, includeSlots);\n result.sort((a, b) => a.urlPath.localeCompare(b.urlPath));\n return result;\n}\n\nfunction walk<TFile>(\n node: SegmentNode<TFile>,\n chain: SegmentNode<TFile>[],\n result: LeafRoute<TFile>[],\n includeSlots: boolean\n): void {\n const currentChain = [...chain, node];\n const path = node.urlPath || '/';\n\n if (node.page) {\n result.push({ urlPath: path, segments: currentChain, page: node.page });\n }\n if (node.route) {\n result.push({ urlPath: path, segments: currentChain, route: node.route });\n }\n\n for (const child of node.children) {\n walk(child, currentChain, result, includeSlots);\n }\n\n if (includeSlots) {\n for (const slotNode of Object.values(node.slots)) {\n walk(slotNode, currentChain, result, includeSlots);\n }\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-output-helper.d.ts","sourceRoot":"","sources":["../../src/adapters/build-output-helper.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C,iDAAiD;AACjD,MAAM,WAAW,sBAAsB;IACrC,8BAA8B;IAC9B,MAAM,EAAE,YAAY,CAAC;IACrB,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,aAAa,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,sBAAsB,GAAG,OAAO,CAAC,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"build-output-helper.d.ts","sourceRoot":"","sources":["../../src/adapters/build-output-helper.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C,iDAAiD;AACjD,MAAM,WAAW,sBAAsB;IACrC,8BAA8B;IAC9B,MAAM,EAAE,YAAY,CAAC;IACrB,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,aAAa,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,sBAAsB,GAAG,OAAO,CAAC,MAAM,CAAC,CAiDvF"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as runSharedBuildSteps } from "../_chunks/build-output-helper-
|
|
1
|
+
import { t as runSharedBuildSteps } from "../_chunks/build-output-helper-DYOWsPjZ.js";
|
|
2
2
|
import { join, relative } from "node:path";
|
|
3
3
|
import { writeFile } from "node:fs/promises";
|
|
4
4
|
import { AsyncLocalStorage } from "node:async_hooks";
|
package/dist/adapters/nitro.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as IMMUTABLE_CACHE, t as runSharedBuildSteps } from "../_chunks/build-output-helper-
|
|
1
|
+
import { n as IMMUTABLE_CACHE, t as runSharedBuildSteps } from "../_chunks/build-output-helper-DYOWsPjZ.js";
|
|
2
2
|
import { join, relative } from "node:path";
|
|
3
3
|
import { readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli-schema-sync.d.ts","sourceRoot":"","sources":["../src/cli-schema-sync.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAK/D;AAeD;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CASjE;AAKD;;GAEG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMjE;
|
|
1
|
+
{"version":3,"file":"cli-schema-sync.d.ts","sourceRoot":"","sources":["../src/cli-schema-sync.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAK/D;AAeD;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CASjE;AAKD;;GAEG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMjE;AA+FD,MAAM,WAAW,gBAAgB;IAC/B,wCAAwC;IACxC,kBAAkB,EAAE,MAAM,EAAE,CAAC;IAC7B,iDAAiD;IACjD,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,gCAAgC;IAChC,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,6CAA6C;IAC7C,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,gBAAgB,CAoDnE"}
|
package/dist/cli.js
CHANGED
|
@@ -119,7 +119,7 @@ async function runCheck(options) {
|
|
|
119
119
|
async function runSchema(args) {
|
|
120
120
|
const subcommand = args[0];
|
|
121
121
|
if (subcommand !== "sync") throw new Error(`Unknown schema subcommand: ${subcommand ?? "(none)"}. Usage: timber schema sync`);
|
|
122
|
-
const { runSchemaSync } = await import("./_chunks/cli-schema-sync-
|
|
122
|
+
const { runSchemaSync } = await import("./_chunks/cli-schema-sync-4HcQIziG.js");
|
|
123
123
|
const result = runSchemaSync(process.cwd());
|
|
124
124
|
if (result.filesystemSegments.length === 0) {
|
|
125
125
|
console.log("[timber] No dynamic segments found in app/.");
|
package/dist/client/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { n as classifyUrlSegment } from "../_chunks/segment-classify-
|
|
2
|
+
import { n as classifyUrlSegment } from "../_chunks/segment-classify-BjfuctV2.js";
|
|
3
3
|
import { t as getSearchParamsDefinition } from "../_chunks/registry-BI9cgCt7.js";
|
|
4
4
|
import { t as _registerUseSegmentParams } from "../_chunks/define-CPVQaWbx.js";
|
|
5
5
|
import { t as mergePreservedSearchParams } from "../_chunks/merge-search-params-az6ZiBeU.js";
|
package/dist/client/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/client/use-link-status.ts","../../src/client/nav-link-store.ts","../../src/client/navigation-api.ts","../../src/client/link.tsx","../../src/client/use-pending-navigation.ts","../../src/client/use-router.ts","../../src/client/use-pathname.ts","../../src/client/use-selected-layout-segment.ts","../../src/client/form.tsx","../../src/client/index.ts"],"sourcesContent":["'use client';\n\n// useLinkStatus — returns { isPending: true } while the nearest parent <Link>'s\n// navigation is in flight. No arguments — scoped via React context.\n// See design/19-client-navigation.md §\"useLinkStatus()\"\n\nimport { useContext, createContext } from 'react';\n\nexport interface LinkStatus {\n isPending: boolean;\n}\n\n/**\n * React context provided by <Link>. Holds the pending status\n * for that specific link's navigation.\n */\nexport const LinkStatusContext = createContext<LinkStatus>({ isPending: false });\n\n/**\n * Returns `{ isPending: true }` while the nearest parent `<Link>` component's\n * navigation is in flight. Must be used inside a `<Link>` component's children.\n *\n * Unlike `usePendingNavigation()` which is global, this hook is scoped to\n * the nearest parent `<Link>` — only the link the user clicked shows pending.\n *\n * ```tsx\n * 'use client'\n * import { Link, useLinkStatus } from '@timber-js/app/client'\n *\n * function Hint() {\n * const { isPending } = useLinkStatus()\n * return <span className={isPending ? 'opacity-50' : ''} />\n * }\n *\n * export function NavLink({ href, children }) {\n * return (\n * <Link href={href}>\n * {children} <Hint />\n * </Link>\n * )\n * }\n * ```\n */\nexport function useLinkStatus(): LinkStatus {\n return useContext(LinkStatusContext);\n}\n","/**\n * Navigation Link Store — passes per-link metadata from Link's onClick\n * to the Navigation API's navigate event handler.\n *\n * When the Navigation API is active, Link does NOT call event.preventDefault()\n * or router.navigate(). Instead it stores metadata (scroll option, link\n * pending instance) here, and lets the <a> click propagate naturally.\n * The navigate event handler reads this metadata to configure the RSC\n * navigation with the correct options.\n *\n * This store is consumed once per navigation — after reading, the metadata\n * is cleared. If no metadata is present (e.g., a plain <a> tag without\n * our Link component), the navigate handler uses default options.\n *\n * See design/19-client-navigation.md §\"Navigation API Integration\"\n */\n\nimport type { LinkPendingInstance } from './link-pending-store.js';\n\nexport interface NavLinkMetadata {\n /** Whether to scroll to top after navigation. Default: true. */\n scroll: boolean;\n /** The Link's pending state instance for per-link status tracking. */\n linkInstance: LinkPendingInstance | null;\n}\n\nlet pendingMetadata: NavLinkMetadata | null = null;\n\n/**\n * Store metadata from Link's onClick for the next navigate event.\n * Called synchronously in the click handler — the navigate event\n * fires synchronously after onClick returns.\n */\nexport function setNavLinkMetadata(metadata: NavLinkMetadata): void {\n pendingMetadata = metadata;\n}\n\n/**\n * Consume the stored metadata. Returns null if no Link onClick\n * preceded this navigation (e.g., plain <a> tag, programmatic nav).\n * Clears the store after reading.\n */\nexport function consumeNavLinkMetadata(): NavLinkMetadata | null {\n const metadata = pendingMetadata;\n pendingMetadata = null;\n return metadata;\n}\n","/**\n * Navigation API integration — progressive enhancement for client navigation.\n *\n * When the Navigation API (`window.navigation`) is available, this module\n * provides an intercept-based navigation model that replaces the separate\n * popstate + click handler approach with a single navigate event listener.\n *\n * Key benefits:\n * - Intercepts ALL navigations (link clicks, form submissions, back/forward)\n * - Built-in AbortSignal per navigation (auto-aborts in-flight fetches)\n * - Per-entry state via NavigationHistoryEntry.getState()\n * - navigation.transition for progress tracking\n *\n * When unavailable, all functions are no-ops and the History API fallback\n * in browser-entry.ts handles navigation.\n *\n * See design/19-client-navigation.md\n */\n\nimport type { NavigationApi, NavigateEvent } from './navigation-api-types.js';\nimport { consumeNavLinkMetadata } from './nav-link-store.js';\nimport { isHardNavigating } from './navigation-root.js';\n\n// ─── Feature Detection ───────────────────────────────────────────\n\n/**\n * Returns true if the Navigation API is available in the current environment.\n * Feature-detected at runtime — no polyfill.\n */\nexport function hasNavigationApi(): boolean {\n return (\n typeof window !== 'undefined' &&\n 'navigation' in window &&\n (window as unknown as { navigation: unknown }).navigation != null\n );\n}\n\n/**\n * Get the Navigation API instance. Returns null if unavailable.\n * Uses type assertion — we never import Navigation API types unconditionally.\n */\nexport function getNavigationApi(): NavigationApi | null {\n if (!hasNavigationApi()) return null;\n return (window as unknown as { navigation: NavigationApi }).navigation;\n}\n\n// ─── Navigation API Controller ───────────────────────────────────\n\n/**\n * Callbacks for the Navigation API event handler.\n *\n * When the Navigation API intercepts a navigation, it delegates to these\n * callbacks which run the RSC fetch + render pipeline.\n */\nexport interface NavigationApiCallbacks {\n /**\n * Handle a push/replace navigation intercepted by the Navigation API.\n * This covers both Link <a> clicks (user-initiated, with metadata from\n * nav-link-store) and external navigations (plain <a> tags, programmatic).\n * The Navigation API handles the URL update via event.intercept().\n */\n onExternalNavigate: (\n url: string,\n options: { replace: boolean; signal: AbortSignal; scroll?: boolean }\n ) => Promise<void>;\n\n /**\n * Handle a traversal (back/forward button). The Navigation API intercepts\n * the traversal and delegates to us for RSC replay/fetch.\n */\n onTraverse: (url: string, scrollY: number, signal: AbortSignal) => Promise<void>;\n}\n\n/**\n * Controller returned by setupNavigationApi. Provides methods to\n * coordinate between the router and the navigate event listener.\n */\nexport interface NavigationApiController {\n /**\n * Set the router-navigating flag. When `true`, the next navigate event\n * (from pushState/replaceState) is recognized as router-initiated. The\n * handler still intercepts it — but ties the browser's native loading\n * state to a deferred promise instead of running the RSC pipeline again.\n *\n * This means `navigation.transition` is active for the full duration of\n * every router-initiated navigation, giving the browser a native loading\n * indicator (tab spinner, address bar) aligned with the TopLoader.\n *\n * Must be called synchronously around pushState/replaceState:\n * controller.setRouterNavigating(true);\n * history.pushState(...); // navigate event fires, intercepted\n * controller.setRouterNavigating(false); // flag off, deferred stays open\n */\n setRouterNavigating: (value: boolean) => void;\n\n /**\n * Resolve the deferred promise created by setRouterNavigating(true),\n * clearing the browser's native loading state. Call this when the\n * navigation fully completes — aligned with when the TopLoader's\n * pendingUrl clears (same finally block in router.navigate).\n */\n completeRouterNavigation: () => void;\n\n /**\n * Initiate a navigation via the Navigation API (`navigation.navigate()`).\n * Unlike `history.pushState()`, this fires the navigate event BEFORE\n * committing the URL — allowing Chrome to show its native loading\n * indicator while the intercept handler runs.\n *\n * Must be called with setRouterNavigating(true) active so the handler\n * recognizes it as router-initiated and uses the deferred promise.\n */\n navigate: (url: string, replace: boolean) => void;\n\n /**\n * Save scroll position into the current navigation entry's state.\n * Uses navigation.updateCurrentEntry() for per-entry scroll storage.\n */\n saveScrollPosition: (scrollY: number) => void;\n\n /**\n * Check if the Navigation API has an active transition.\n * Returns the transition object if available, null otherwise.\n */\n hasActiveTransition: () => boolean;\n\n /** Remove the navigate event listener. */\n cleanup: () => void;\n}\n\n/**\n * Set up the Navigation API navigate event listener.\n *\n * Intercepts same-origin navigations and delegates to the provided callbacks.\n * Router-initiated navigations (pushState from router.navigate) are detected\n * via a synchronous flag and NOT intercepted — the router already handles them.\n *\n * Returns a controller for coordinating with the router.\n */\nexport function setupNavigationApi(callbacks: NavigationApiCallbacks): NavigationApiController {\n const nav = getNavigationApi()!;\n\n let routerNavigating = false;\n\n // Deferred promise for router-initiated navigations. Created when\n // setRouterNavigating(true) is called, resolved by completeRouterNavigation().\n // The navigate event handler intercepts with this promise so the browser's\n // native loading state (tab spinner) stays active until the navigation\n // completes — aligned with TopLoader's pendingUrl lifecycle.\n let routerNavDeferred: { promise: Promise<void>; resolve: () => void } | null = null;\n\n function handleNavigate(event: NavigateEvent): void {\n // Skip non-interceptable navigations (cross-origin, etc.)\n if (!event.canIntercept) return;\n\n // Hard navigation guard: when the router has triggered a full page\n // load (500 error, version skew), skip interception entirely so the\n // browser performs the MPA navigation. Without this guard, setting\n // window.location.href fires a navigate event that we'd intercept,\n // running the RSC pipeline again → 500 → window.location.href →\n // navigate event → infinite loop.\n // See design/19-client-navigation.md §\"Hard Navigation Guard\"\n if (isHardNavigating()) return;\n\n // Skip download requests\n if (event.downloadRequest) return;\n\n // Skip hash-only changes — let the browser handle scroll-to-anchor\n if (event.hashChange) return;\n\n // Shallow URL updates (e.g., nuqs search param changes). The navigation\n // only changes the URL — no server round trip needed. Intercept with a\n // no-op handler so the Navigation API commits the URL change without\n // triggering a full page navigation (which is the default if we don't\n // intercept). The info property is the Navigation API's built-in\n // per-navigation metadata — no side-channel flags needed.\n const info = event.info as { shallow?: boolean } | null | undefined;\n if (info?.shallow) {\n event.intercept({\n handler: () => Promise.resolve(),\n focusReset: 'manual',\n scroll: 'manual',\n });\n return;\n }\n\n // Skip form submissions with a body (POST/PUT/etc.). These need the\n // browser's native form handling to send the request body to the server.\n // Intercepting would convert them into GET RSC navigations, dropping\n // the form data. Server actions use fetch() directly (not form navigation),\n // so they are unaffected by this check.\n if (event.formData) return;\n\n // Skip cross-origin (defense-in-depth — canIntercept covers this)\n const destUrl = new URL(event.destination.url);\n if (destUrl.origin !== location.origin) return;\n\n // Router-initiated navigation (Link click → router.navigate → pushState).\n // The router is already running the RSC pipeline — don't run it again.\n // Instead, intercept with the deferred promise so the browser's native\n // loading state tracks the navigation's full lifecycle. This aligns the\n // tab spinner / address bar indicator with the TopLoader.\n if (routerNavigating && routerNavDeferred) {\n event.intercept({\n scroll: 'manual',\n focusReset: 'manual',\n handler: () => routerNavDeferred!.promise,\n });\n return;\n }\n\n // Skip reload navigations — let the browser handle full page reload\n if (event.navigationType === 'reload') return;\n\n const url = destUrl.pathname + destUrl.search;\n\n if (event.navigationType === 'traverse') {\n // Back/forward button — intercept and delegate to router.\n // Read scroll position from the destination entry's state.\n const entryState = event.destination.getState() as\n | { scrollY?: number; timber?: boolean }\n | null\n | undefined;\n const scrollY = entryState && typeof entryState.scrollY === 'number' ? entryState.scrollY : 0;\n\n event.intercept({\n // Manual scroll — we handle scroll restoration ourselves\n // via afterPaint (same as the History API path).\n scroll: 'manual',\n focusReset: 'manual',\n async handler() {\n await callbacks.onTraverse(url, scrollY, event.signal);\n },\n });\n } else if (event.navigationType === 'push' || event.navigationType === 'replace') {\n // Push/replace — either a Link <a> click (with metadata in\n // nav-link-store) or an external navigation (plain <a>, programmatic).\n // Consume link metadata if present — tells us scroll preference\n // and which Link component to track pending state for.\n const linkMeta = consumeNavLinkMetadata();\n\n // Save the departing page's scroll position BEFORE event.intercept()\n // commits the URL change. Once intercept() is called, currentEntry\n // switches to the new (destination) entry — any updateCurrentEntry()\n // call after that would save to the wrong entry.\n // See: router.navigate() also calls saveNavigationEntryScroll(), but\n // for Navigation API <a> click navigations (where Link does NOT call\n // router.navigate directly), the router's save runs inside the\n // intercept handler — too late, currentEntry has already switched.\n try {\n const currentState = (nav.currentEntry?.getState() ?? {}) as Record<string, unknown>;\n nav.updateCurrentEntry({\n state: { ...currentState, timber: true, scrollY: window.scrollY },\n });\n } catch {\n // Ignore — entry may be disposed\n }\n\n event.intercept({\n scroll: 'manual',\n focusReset: 'manual',\n async handler() {\n await callbacks.onExternalNavigate(url, {\n replace: event.navigationType === 'replace',\n signal: event.signal,\n scroll: linkMeta?.scroll,\n });\n },\n });\n }\n }\n\n nav.addEventListener('navigate', handleNavigate as EventListener);\n\n return {\n setRouterNavigating(value: boolean): void {\n routerNavigating = value;\n if (value) {\n // Create a new deferred promise. The navigate event handler will\n // intercept and tie the browser's loading state to this promise.\n let resolve!: () => void;\n const promise = new Promise<void>((r) => {\n resolve = r;\n });\n routerNavDeferred = { promise, resolve };\n } else {\n // Flag off — but DON'T resolve the deferred here. The navigation\n // is still in flight (RSC fetch + render). completeRouterNavigation()\n // resolves it when the navigation fully completes.\n routerNavigating = false;\n }\n },\n\n completeRouterNavigation(): void {\n if (routerNavDeferred) {\n routerNavDeferred.resolve();\n routerNavDeferred = null;\n }\n },\n\n navigate(url: string, replace: boolean): void {\n // Use navigation.navigate() instead of history.pushState().\n // This fires the navigate event BEFORE committing the URL,\n // which lets Chrome show its native loading indicator while\n // the intercept handler (deferred promise) is pending.\n // history.pushState() commits the URL synchronously, so Chrome\n // sees the navigation as already complete and skips the indicator.\n nav.navigate(url, {\n history: replace ? 'replace' : 'push',\n });\n },\n\n saveScrollPosition(scrollY: number): void {\n try {\n const currentState = (nav.currentEntry?.getState() ?? {}) as Record<string, unknown>;\n nav.updateCurrentEntry({\n state: { ...currentState, timber: true, scrollY },\n });\n } catch {\n // Ignore errors — updateCurrentEntry may throw if entry is disposed\n }\n },\n\n hasActiveTransition(): boolean {\n return nav.transition != null;\n },\n\n cleanup(): void {\n nav.removeEventListener('navigate', handleNavigate as EventListener);\n },\n };\n}\n","'use client';\n\n// Link component — client-side navigation with progressive enhancement\n// See design/19-client-navigation.md § Progressive Enhancement\n//\n// Without JavaScript, <Link> renders as a plain <a> tag — standard browser\n// navigation. With JavaScript, the Link component's onClick handler triggers\n// RSC-based client navigation via the router.\n//\n// Each Link owns its own click handler — no global event delegation.\n// This keeps navigation within React's component tree, ensuring pending\n// state (useLinkStatus) updates atomically with the navigation.\n//\n// Typed Link: design/09-typescript.md §\"Typed Link\"\n// - href validated against known routes (via codegen overloads, not runtime)\n// - params prop typed per-route, URL interpolated at runtime\n// - searchParams prop serialized via SearchParamsDefinition\n// - params and fully-resolved string href are mutually exclusive\n// - searchParams and inline query string are mutually exclusive\n\nimport {\n useOptimistic,\n useEffect,\n useRef,\n type AnchorHTMLAttributes,\n type ReactNode,\n type MouseEvent as ReactMouseEvent,\n} from 'react';\nimport type { SearchParamsDefinition } from '../search-params/define.js';\nimport { getSearchParamsDefinition } from '../search-params/registry.js';\nimport type { LinkFunction } from './index.js';\nimport { classifyUrlSegment, type UrlSegment } from '../routing/segment-classify.js';\nimport { LinkStatusContext } from './use-link-status.js';\nimport { getRouterOrNull } from './router-ref.js';\nimport { getSsrData } from './ssr-data.js';\nimport { mergePreservedSearchParams } from '../shared/merge-search-params.js';\nimport {\n setLinkForCurrentNavigation,\n unmountLinkForCurrentNavigation,\n LINK_IDLE,\n type LinkPendingInstance,\n} from './link-pending-store.js';\nimport { setNavLinkMetadata } from './nav-link-store.js';\nimport { hasNavigationApi } from './navigation-api.js';\nimport { getLinkCodec } from '../params/codec-registry.js';\n\n// ─── Current Search Params ────────────────────────────────────────\n\n/**\n * Read the current URL's search string without requiring a React hook.\n * On the client, reads window.location.search. During SSR, reads from\n * the request context (getSsrData). Returns empty string if unavailable.\n */\nfunction getCurrentSearch(): string {\n if (typeof window !== 'undefined') return window.location.search;\n const data = getSsrData();\n if (!data) return '';\n const sp = new URLSearchParams(data.searchParams);\n const str = sp.toString();\n return str ? `?${str}` : '';\n}\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport type OnNavigateEvent = {\n preventDefault: () => void;\n};\n\nexport type OnNavigateHandler = (e: OnNavigateEvent) => void;\n\n/**\n * Base props shared by all Link variants.\n *\n * Exported so the public `LinkFunction` interface (declared in\n * `./index.ts`, where module augmentation can merge into it) can\n * compose this without duplication.\n */\nexport interface LinkBaseProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {\n /** Prefetch the RSC payload on hover */\n prefetch?: boolean;\n /**\n * Scroll to top on navigation. Defaults to true.\n * Set to false for tabbed interfaces where content changes within a fixed layout.\n */\n scroll?: boolean;\n /**\n * Preserve search params from the current URL across navigation.\n *\n * - `true` — preserve ALL current search params (target params take precedence)\n * - `string[]` — preserve only the named params (e.g. `['private', 'token']`)\n *\n * Useful for route-group gating where a search param (e.g. `?private=access`)\n * must persist across internal navigations. The target href's own search params\n * always take precedence over preserved ones.\n *\n * During SSR, reads search params from the request context. On the client,\n * reads from the current URL and updates reactively when the URL changes.\n */\n preserveSearchParams?: true | string[];\n /**\n * Called before client-side navigation commits. Call `e.preventDefault()`\n * to cancel the default navigation — the caller is then responsible for\n * navigating (e.g. via `router.push()`).\n *\n * Only fires for client-side SPA navigations, not full page loads.\n * Has no effect during SSR.\n */\n onNavigate?: OnNavigateHandler;\n children?: ReactNode;\n}\n\n// ─── Typed Link Props ────────────────────────────────────────────\n\n/**\n * Widen server-side string params to string | number for Link convenience.\n * Exported for use by codegen-generated overloads.\n */\nexport type LinkSegmentParams<T> = {\n [K in keyof T]: [string] extends [T[K]] ? string | number : T[K];\n};\n\n// ─── External Href Types ─────────────────────────────────────────\n//\n// `ExternalHref` and the public `LinkFunction` interface live in\n// `./index.ts` rather than this file. They MUST be originally declared\n// in the same module that the codegen augments (`@timber-js/app/client`)\n// so that codegen-generated per-route call signatures merge with the\n// same interface that types the `Link` constant. Re-exporting an\n// interface via `export type {}` does NOT participate in module\n// augmentation merging — only originally-declared interfaces do.\n// See TIM-624.\n\n// ─── searchParams prop shapes ────────────────────────────────────\n//\n// Per-route Link overloads (emitted by codegen) pass the flat values shape:\n// searchParams={{ page: 2, q: 'boots' }}\n// The framework looks up the route's SearchParamsDefinition from the\n// search-params registry at runtime (see TIM-830).\n//\n// The catch-all overload in client/index.ts (external/computed hrefs)\n// additionally accepts the legacy wrapped shape:\n// searchParams={{ definition: def, values: { page: 2 } }}\n// because there is no way to look up a definition from a computed string.\n//\n// `resolveHref` discriminates at runtime by presence of a `definition` key.\ntype WrappedSearchParamsProp = {\n definition: SearchParamsDefinition<Record<string, unknown>>;\n values: Record<string, unknown>;\n};\ntype FlatSearchParamsProp = Record<string, unknown>;\ntype LinkSearchParamsProp = WrappedSearchParamsProp | FlatSearchParamsProp;\n\n/**\n * Runtime-only loose props used internally by the Link implementation.\n * Not exposed to callers — the public API uses LinkFunction.\n */\ninterface LinkRuntimeProps extends LinkBaseProps {\n href: string;\n segmentParams?: Record<string, string | number | string[]>;\n searchParams?: LinkSearchParamsProp;\n}\n\n// Legacy exports for backward compat (used by buildLinkProps, tests, etc.)\nexport type LinkPropsWithHref = LinkBaseProps & {\n href: string;\n segmentParams?: never;\n searchParams?: LinkSearchParamsProp;\n};\nexport type LinkPropsWithParams = LinkRuntimeProps & {\n segmentParams: Record<string, string | number | string[]>;\n};\nexport type LinkProps = LinkRuntimeProps;\n\n// ─── Dangerous URL Scheme Detection ──────────────────────────────\n\n/**\n * Reject dangerous URL schemes that could execute script.\n * Security: design/13-security.md § Link scheme injection (test #9)\n */\nconst DANGEROUS_SCHEMES = /^\\s*(javascript|data|vbscript):/i;\n\nexport function validateLinkHref(href: string): void {\n if (DANGEROUS_SCHEMES.test(href)) {\n throw new Error(\n `<Link> received a dangerous href: \"${href}\". ` +\n 'javascript:, data:, and vbscript: URLs are not allowed.'\n );\n }\n}\n\n// ─── Internal Link Detection ─────────────────────────────────────\n\n/** Returns true if the href is an internal path (not an external URL) */\nexport function isInternalHref(href: string): boolean {\n // Relative paths, root-relative paths, and hash links are internal\n if (href.startsWith('/') || href.startsWith('#') || href.startsWith('?')) {\n return true;\n }\n // Anything with a protocol scheme is external\n if (/^[a-z][a-z0-9+.-]*:/i.test(href)) {\n return false;\n }\n // Bare relative paths (e.g., \"dashboard\") are internal\n return true;\n}\n\n// ─── URL Interpolation ──────────────────────────────────────────\n\n/**\n * Interpolate dynamic segments in a route pattern with actual values.\n * e.g. interpolateParams(\"/products/[id]\", { id: \"123\" }) → \"/products/123\"\n *\n * Supports:\n * - [param] → single segment\n * - [...param] → catch-all (joined with /)\n * - [[...param]] → optional catch-all (omitted if undefined/empty)\n */\n/**\n * Parse a route pattern's path portion into classified segments.\n * Exported for testing. Uses the shared character-based classifier.\n */\nexport function parseSegments(pattern: string): UrlSegment[] {\n return pattern.split('/').filter(Boolean).map(classifyUrlSegment);\n}\n\n/**\n * Resolve a single classified segment into its string representation.\n * Returns null for optional catch-all with no value (filtered out before join).\n *\n * When schema codecs are registered (via virtual:timber-schema), uses\n * codec.serialize() for URL construction instead of plain String().\n */\nfunction resolveSegment(\n seg: UrlSegment,\n params: Record<string, string | number | string[]>,\n pattern: string\n): string | null {\n switch (seg.kind) {\n case 'static':\n return seg.value;\n\n case 'optional-catch-all': {\n const value = params[seg.name];\n if (value === undefined || (Array.isArray(value) && value.length === 0)) {\n return null;\n }\n const codec = getLinkCodec(`[[...${seg.name}]]`);\n if (codec) {\n const serialized = codec.serialize(value);\n if (!serialized) return null;\n // Codec returns a path string (may contain '/') — encode each segment\n return serialized.split('/').map(encodeURIComponent).join('/');\n }\n const segments = Array.isArray(value) ? value : [value];\n return segments.map(encodeURIComponent).join('/');\n }\n\n case 'catch-all': {\n const value = params[seg.name];\n if (value === undefined) {\n throw new Error(\n `<Link> missing required catch-all param \"${seg.name}\" for pattern \"${pattern}\".`\n );\n }\n const codec = getLinkCodec(`[...${seg.name}]`);\n if (codec) {\n const serialized = codec.serialize(value);\n if (!serialized) {\n throw new Error(\n `<Link> catch-all param \"${seg.name}\" codec returned null for pattern \"${pattern}\".`\n );\n }\n // Codec returns a path string (may contain '/') — encode each segment\n return serialized.split('/').map(encodeURIComponent).join('/');\n }\n const segments = Array.isArray(value) ? value : [value];\n if (segments.length === 0) {\n throw new Error(\n `<Link> catch-all param \"${seg.name}\" must have at least one segment for pattern \"${pattern}\".`\n );\n }\n return segments.map(encodeURIComponent).join('/');\n }\n\n case 'dynamic': {\n const value = params[seg.name];\n if (value === undefined) {\n throw new Error(`<Link> missing required param \"${seg.name}\" for pattern \"${pattern}\".`);\n }\n if (Array.isArray(value)) {\n throw new Error(\n `<Link> param \"${seg.name}\" expected a string but received an array for pattern \"${pattern}\".`\n );\n }\n const codec = getLinkCodec(`[${seg.name}]`);\n const str = codec ? (codec.serialize(value) ?? String(value)) : String(value);\n return encodeURIComponent(str);\n }\n }\n}\n\n/**\n * Split a URL pattern into the path portion and any trailing ?query/#hash suffix.\n * Uses URL parsing for correctness rather than manual index arithmetic.\n */\nfunction splitPatternSuffix(pattern: string): [path: string, suffix: string] {\n if (!pattern.includes('?') && !pattern.includes('#')) {\n return [pattern, ''];\n }\n const url = new URL(pattern, 'http://x');\n const suffix = url.search + url.hash;\n const path = pattern.slice(0, pattern.length - suffix.length);\n return [path, suffix];\n}\n\nexport function interpolateParams(\n pattern: string,\n params: Record<string, string | number | string[]>\n): string {\n const [pathPart, suffix] = splitPatternSuffix(pattern);\n\n const resolved = parseSegments(pathPart)\n .map((seg) => resolveSegment(seg, params, pattern))\n .filter((s): s is string => s !== null);\n return ('/' + resolved.join('/') || '/') + suffix;\n}\n\n// ─── Resolve Href ───────────────────────────────────────────────\n\n/**\n * Resolve the final href string from Link props.\n *\n * Handles:\n * - params interpolation into route patterns\n * - searchParams serialization via SearchParamsDefinition\n * - Validation that searchParams and inline query strings are exclusive\n */\n/**\n * Runtime discriminator: treat `searchParams` as the legacy wrapped shape\n * only when it literally has a `definition` key. Everything else is the\n * flat `Partial<T>` values shape (TIM-830).\n */\nfunction isWrappedSearchParamsProp(sp: LinkSearchParamsProp): sp is WrappedSearchParamsProp {\n return 'definition' in sp;\n}\n\nexport function resolveHref(\n href: string,\n params?: Record<string, string | number | string[]>,\n searchParams?: LinkSearchParamsProp\n): string {\n let resolvedPath = href;\n\n // Interpolate params if provided\n if (params) {\n resolvedPath = interpolateParams(href, params);\n }\n\n // Serialize searchParams if provided\n if (searchParams) {\n // Validate: searchParams prop and inline query string are mutually exclusive\n if (resolvedPath.includes('?')) {\n throw new Error(\n '<Link> received both a searchParams prop and a query string in href. ' +\n 'These are mutually exclusive — use one or the other.'\n );\n }\n\n let definition: SearchParamsDefinition<Record<string, unknown>> | undefined;\n let values: Record<string, unknown>;\n\n if (isWrappedSearchParamsProp(searchParams)) {\n // Legacy wrapped shape — used by the catch-all overload for\n // computed/external hrefs where no route lookup is possible.\n definition = searchParams.definition;\n values = searchParams.values;\n } else {\n // Flat shape (TIM-830): look up the definition from the runtime\n // registry using the un-interpolated href pattern (e.g. '/products/[id]').\n // The search-params registry is populated eagerly at startup by the\n // virtual:timber-search-params-registry module generated by the\n // timber-routing Vite plugin.\n definition = getSearchParamsDefinition(href) as\n | SearchParamsDefinition<Record<string, unknown>>\n | undefined;\n values = searchParams;\n if (!definition) {\n throw new Error(\n `<Link> received a flat searchParams object for href \"${href}\", but no ` +\n `SearchParamsDefinition is registered for that route. ` +\n `Either the route does not export \\`searchParams\\` from its \\`params.ts\\`/\\`page.tsx\\`, ` +\n `or the search-params registry module was not loaded. ` +\n `For external or computed hrefs, pass the legacy \\`{ definition, values }\\` shape instead.`\n );\n }\n }\n\n const qs = definition.serialize(values);\n if (qs) {\n resolvedPath = `${resolvedPath}?${qs}`;\n }\n }\n\n return resolvedPath;\n}\n\n// ─── Build Props ─────────────────────────────────────────────────\n\ninterface LinkOutputProps {\n href: string;\n}\n\n/**\n * Build the HTML attributes for a Link. Separated from the component\n * for testability — the component just spreads these onto an <a>.\n */\nexport function buildLinkProps(\n props: Pick<LinkPropsWithHref, 'href'> & {\n params?: Record<string, string | number | string[]>;\n searchParams?: LinkSearchParamsProp;\n }\n): LinkOutputProps {\n const resolvedHref = resolveHref(props.href, props.params, props.searchParams);\n validateLinkHref(resolvedHref);\n return { href: resolvedHref };\n}\n\n// ─── Click Handler ───────────────────────────────────────────────\n\n/**\n * Should this click be intercepted for SPA navigation?\n *\n * Returns false (pass through to browser) when:\n * - Modified keys are held (Ctrl, Meta, Shift, Alt) — open in new tab\n * - The click is not the primary button\n * - The event was already prevented by a parent handler\n * - The link has target=\"_blank\" or similar\n * - The link has a download attribute\n * - The href is external\n */\nfunction shouldInterceptClick(\n event: ReactMouseEvent<HTMLAnchorElement>,\n resolvedHref: string\n): boolean {\n if (event.button !== 0) return false;\n if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;\n if (event.defaultPrevented) return false;\n\n const anchor = event.currentTarget;\n if (anchor.target && anchor.target !== '_self') return false;\n if (anchor.hasAttribute('download')) return false;\n\n if (!isInternalHref(resolvedHref)) return false;\n\n return true;\n}\n\n// ─── Link Component ──────────────────────────────────────────────\n\n/**\n * Navigation link with progressive enhancement.\n *\n * Renders as a plain `<a>` tag — works without JavaScript. When the client\n * runtime is active, the Link's onClick handler triggers RSC-based client\n * navigation via the router. No global event delegation — each Link owns\n * its own click handling.\n *\n * Supports typed routes via the Routes interface (populated by codegen).\n * At runtime:\n * - `segmentParams` prop interpolates dynamic segments in the href pattern\n * - `searchParams` prop serializes query parameters via a SearchParamsDefinition\n *\n * Typed via the LinkFunction callable interface. The base call signature\n * forbids segmentParams; per-route signatures are added by codegen via\n * interface merging. See TIM-624.\n */\n// Cast to LinkFunction — the callable interface provides the public type,\n// but the implementation destructures LinkRuntimeProps internally.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport const Link: LinkFunction = function LinkImpl(props: any) {\n const {\n href,\n prefetch,\n scroll,\n segmentParams,\n searchParams,\n preserveSearchParams,\n onNavigate,\n onClick: userOnClick,\n onMouseEnter: userOnMouseEnter,\n children,\n ...rest\n } = props as LinkRuntimeProps;\n const { href: baseHref } = buildLinkProps({ href, params: segmentParams, searchParams });\n\n // ─── Per-link pending state (useOptimistic) ────────────────────────\n // Each Link has its own pending state. Only the clicked link's\n // setter is invoked during navigation — zero other links re-render.\n //\n // Link click stores the instance; NavigationRoot activates inside startTransition.\n // useOptimistic auto-reverts to LINK_IDLE when the navigation\n // startTransition — batched with the new tree commit.\n //\n // See design/19-client-navigation.md §\"Per-Link Pending State\"\n const [linkStatus, setIsPending] = useOptimistic(LINK_IDLE);\n\n // Build the link instance ref for the pending store.\n // The ref is stable across renders — we update the setter on each\n // render to keep it current.\n const linkInstanceRef = useRef<LinkPendingInstance | null>(null);\n if (!linkInstanceRef.current) {\n linkInstanceRef.current = { setIsPending };\n } else {\n linkInstanceRef.current.setIsPending = setIsPending;\n }\n\n // Clean up if this link unmounts while it's the current navigation link.\n // Prevents calling setOptimistic on an unmounted component.\n useEffect(() => {\n const instance = linkInstanceRef.current;\n return () => {\n if (instance) {\n unmountLinkForCurrentNavigation(instance);\n }\n };\n }, []);\n\n // Preserve search params from the current URL when requested.\n // useSearchParams() works during both SSR (reads from request context)\n // and on the client (reads from window.location, reactive to URL changes).\n // We read current search params directly to avoid unconditional hook calls.\n // On the client, window.location.search is always current; during SSR,\n // getSsrData() provides the request's search params.\n const resolvedHref = preserveSearchParams\n ? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)\n : baseHref;\n\n const internal = isInternalHref(resolvedHref);\n\n // ─── Click handler ───────────────────────────────────────────\n // Each Link component owns its click handling. The router is\n // accessed via the singleton ref — during SSR, getRouterOrNull()\n // returns null and onClick is a no-op (the <a> works as a plain link).\n const handleClick = internal\n ? (event: ReactMouseEvent<HTMLAnchorElement>) => {\n // Call user's onClick first (e.g., analytics)\n userOnClick?.(event);\n\n if (!shouldInterceptClick(event, resolvedHref)) return;\n\n // Call onNavigate if provided — allows caller to cancel\n if (onNavigate) {\n let prevented = false;\n onNavigate({\n preventDefault: () => {\n prevented = true;\n },\n });\n if (prevented) {\n event.preventDefault();\n return;\n }\n }\n\n const router = getRouterOrNull();\n if (!router) return; // SSR or pre-hydration — fall through to browser nav\n\n const shouldScroll = scroll !== false;\n\n // Register this link in the pending store. The actual\n // setIsPending(LINK_PENDING) call happens inside NavigationRoot's\n // async startTransition via activateLinkPending().\n\n setLinkForCurrentNavigation(linkInstanceRef.current);\n\n // When Navigation API is active, let the <a> click propagate\n // naturally — do NOT call preventDefault(). The navigate event\n // handler intercepts it and runs the RSC pipeline. This is a\n // user-initiated navigation, so Chrome shows the native loading\n // indicator (tab spinner). Metadata (scroll, link instance) is\n // passed via nav-link-store so the handler can configure the nav.\n //\n // Without Navigation API (fallback), preventDefault and drive\n // navigation through the router as before.\n if (hasNavigationApi()) {\n setNavLinkMetadata({\n scroll: shouldScroll,\n linkInstance: linkInstanceRef.current,\n });\n // Don't preventDefault — let the <a> click fire the navigate event\n return;\n }\n\n // History API fallback — prevent default and navigate via router\n event.preventDefault();\n\n // Re-merge preserved search params at click time to pick up any\n // URL changes since render (e.g. from other navigations or pushState).\n const navHref = preserveSearchParams\n ? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)\n : resolvedHref;\n\n void router.navigate(navHref, { scroll: shouldScroll });\n }\n : userOnClick; // External links — just pass through user's onClick\n\n // ─── Hover prefetch ──────────────────────────────────────────\n const handleMouseEnter =\n internal && prefetch\n ? (event: ReactMouseEvent<HTMLAnchorElement>) => {\n userOnMouseEnter?.(event);\n const router = getRouterOrNull();\n if (router) {\n // Re-merge preserved search params at hover time for fresh prefetch URL\n const prefetchHref = preserveSearchParams\n ? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)\n : resolvedHref;\n router.prefetch(prefetchHref);\n }\n }\n : userOnMouseEnter;\n\n return (\n <a {...rest} href={resolvedHref} onClick={handleClick} onMouseEnter={handleMouseEnter}>\n <LinkStatusContext.Provider value={linkStatus}>{children}</LinkStatusContext.Provider>\n </a>\n );\n};\n","// usePendingNavigation — returns true while an RSC navigation is in flight.\n// See design/19-client-navigation.md §\"usePendingNavigation()\"\n//\n// Reads from PendingNavigationContext (provided by NavigationRoot) so the\n// pending state shows immediately (urgent update) and clears atomically\n// with the new tree (same startTransition commit).\n\nimport { usePendingNavigationUrl } from './navigation-context.js';\n\n/**\n * Returns true while an RSC navigation is in flight.\n *\n * The pending state is true from the moment the RSC fetch starts until\n * React reconciliation completes. This includes the fetch itself,\n * RSC stream parsing, and React tree reconciliation.\n *\n * It does NOT include Suspense streaming after the shell — only the\n * initial shell reconciliation.\n *\n * ```tsx\n * 'use client'\n * import { usePendingNavigation } from '@timber-js/app/client'\n *\n * export function NavBar() {\n * const isPending = usePendingNavigation()\n * return (\n * <nav className={isPending ? 'opacity-50' : ''}>\n * <Link href=\"/dashboard\">Dashboard</Link>\n * </nav>\n * )\n * }\n * ```\n */\nexport function usePendingNavigation(): boolean {\n const pendingUrl = usePendingNavigationUrl();\n // During SSR or outside PendingNavigationProvider, no navigation is pending\n return pendingUrl !== null;\n}\n","/**\n * useRouter() — client-side hook for programmatic navigation.\n *\n * Returns a router instance with push, replace, refresh, back, forward,\n * and prefetch methods. Compatible with Next.js's `useRouter()` from\n * `next/navigation` (App Router).\n *\n * This wraps timber's internal RouterInstance in the Next.js-compatible\n * AppRouterInstance shape that ecosystem libraries expect.\n *\n * NOTE: Unlike Next.js, these methods do NOT wrap navigation in\n * startTransition. In Next.js, router state is React state (useReducer)\n * so startTransition defers the update and provides isPending tracking.\n * In timber, navigation calls reactRoot.render() which is a root-level\n * render — startTransition has no effect on root renders.\n *\n * Navigation state (params, pathname) is delivered atomically via\n * NavigationContext embedded in the element tree passed to\n * reactRoot.render(). See design/19-client-navigation.md §\"NavigationContext\".\n *\n * For loading UI during navigation, use:\n * - useLinkStatus() — per-link pending indicator (inside <Link>)\n * - usePendingNavigation() — global navigation pending state\n */\n\nimport { getRouterOrNull } from './router-ref.js';\n\nexport interface AppRouterInstance {\n /** Navigate to a URL, pushing a new history entry */\n push(href: string, options?: { scroll?: boolean }): void;\n /** Navigate to a URL, replacing the current history entry */\n replace(href: string, options?: { scroll?: boolean }): void;\n /** Refresh the current page (re-fetch RSC payload) */\n refresh(): void;\n /** Navigate back in history */\n back(): void;\n /** Navigate forward in history */\n forward(): void;\n /** Prefetch an RSC payload for a URL */\n prefetch(href: string): void;\n}\n\n/**\n * Get a router instance for programmatic navigation.\n *\n * Compatible with Next.js's `useRouter()` from `next/navigation`.\n *\n * Methods lazily resolve the global router when invoked (during user\n * interaction) rather than capturing it at render time. This is critical\n * because during hydration, React synchronously executes component render\n * functions *before* the router is bootstrapped in browser-entry.ts.\n * If we eagerly captured the router during render, components would get\n * a null reference and be stuck with silent no-ops forever.\n *\n * Returns safe no-ops during SSR or before bootstrap. The `typeof window`\n * check is insufficient because Vite's client SSR environment defines\n * `window`, so we use a try/catch on getRouter() — but only at method\n * invocation time, not at render time.\n */\nexport function useRouter(): AppRouterInstance {\n return {\n push(href: string, options?: { scroll?: boolean }) {\n const router = getRouterOrNull();\n if (!router) {\n if (process.env.NODE_ENV === 'development') {\n console.error(\n '[timber] useRouter().push() called but router is not initialized. This is a bug — please report it.'\n );\n }\n return;\n }\n void router.navigate(href, { scroll: options?.scroll });\n },\n replace(href: string, options?: { scroll?: boolean }) {\n const router = getRouterOrNull();\n if (!router) {\n if (process.env.NODE_ENV === 'development') {\n console.error('[timber] useRouter().replace() called but router is not initialized.');\n }\n return;\n }\n void router.navigate(href, { scroll: options?.scroll, replace: true });\n },\n refresh() {\n const router = getRouterOrNull();\n if (!router) {\n if (process.env.NODE_ENV === 'development') {\n console.error('[timber] useRouter().refresh() called but router is not initialized.');\n }\n return;\n }\n void router.refresh();\n },\n back() {\n if (typeof window !== 'undefined') window.history.back();\n },\n forward() {\n if (typeof window !== 'undefined') window.history.forward();\n },\n prefetch(href: string) {\n const router = getRouterOrNull();\n if (!router) return; // Silent — prefetch failure is non-fatal\n router.prefetch(href);\n },\n };\n}\n","/**\n * usePathname() — client-side hook for reading the current pathname.\n *\n * Returns the pathname portion of the current URL (e.g. '/dashboard/settings').\n * Updates when client-side navigation changes the URL.\n *\n * On the client, reads from NavigationContext which is updated atomically\n * with the RSC tree render. This replaces the previous useSyncExternalStore\n * approach which only subscribed to popstate events — meaning usePathname()\n * did NOT re-render on forward navigation (pushState). The context approach\n * fixes this: pathname updates in the same render pass as the new tree.\n *\n * During SSR, reads the request pathname from the SSR ALS context\n * (populated by ssr-entry.ts) instead of window.location.\n *\n * Compatible with Next.js's `usePathname()` from `next/navigation`.\n */\n\nimport { getSsrData } from './ssr-data.js';\nimport { useNavigationContext } from './navigation-context.js';\n\n/**\n * Read the current URL pathname.\n *\n * On the client, reads from NavigationContext (provided by\n * NavigationProvider in renderRoot). During SSR, reads from the\n * ALS-backed SSR data context. Falls back to window.location.pathname\n * when called outside a React component (e.g., in tests).\n */\nexport function usePathname(): string {\n // Try reading from NavigationContext (client-side, inside React tree).\n // During SSR, no NavigationProvider is mounted, so this returns null.\n try {\n const navContext = useNavigationContext();\n if (navContext !== null) {\n return navContext.pathname;\n }\n } catch {\n // No React dispatcher available (called outside a component).\n // Fall through to SSR/fallback below.\n }\n\n // SSR path: read from ALS-backed SSR data context.\n const ssrData = getSsrData();\n if (ssrData) return ssrData.pathname ?? '/';\n\n // Final fallback: window.location (tests, edge cases).\n if (typeof window !== 'undefined') return window.location.pathname;\n return '/';\n}\n","/**\n * useSelectedLayoutSegment / useSelectedLayoutSegments — client-side hooks\n * for reading the active segment(s) below the current layout.\n *\n * These hooks are used by navigation UIs to highlight active sections.\n * They match Next.js's API from next/navigation.\n *\n * How they work:\n * 1. Each layout is wrapped with a SegmentProvider that records its depth\n * (the URL segments from root to that layout level).\n * 2. The hooks read the current URL pathname via usePathname().\n * 3. They compare the layout's segment depth against the full URL segments\n * to determine which child segments are \"selected\" below.\n *\n * Example: For URL \"/dashboard/settings/profile\"\n * - Root layout (depth 0, segments: ['']): selected segment = \"dashboard\"\n * - Dashboard layout (depth 1, segments: ['', 'dashboard']): selected = \"settings\"\n * - Settings layout (depth 2, segments: ['', 'dashboard', 'settings']): selected = \"profile\"\n *\n * Design docs: design/19-client-navigation.md, design/14-ecosystem.md\n */\n\n'use client';\n\nimport { useSegmentContext } from './segment-context.js';\nimport { usePathname } from './use-pathname.js';\n\n/**\n * Split a pathname into URL segments.\n * \"/\" → [\"\"]\n * \"/dashboard\" → [\"\", \"dashboard\"]\n * \"/dashboard/settings\" → [\"\", \"dashboard\", \"settings\"]\n */\nexport function pathnameToSegments(pathname: string): string[] {\n return pathname.split('/');\n}\n\n/**\n * Pure function: compute the selected child segment given a layout's segment\n * depth and the current URL pathname.\n *\n * @param contextSegments — segments from root to the calling layout, or null if no context\n * @param pathname — current URL pathname\n * @returns the active child segment one level below, or null if at the leaf\n */\nexport function getSelectedSegment(\n contextSegments: string[] | null,\n pathname: string\n): string | null {\n const urlSegments = pathnameToSegments(pathname);\n\n if (!contextSegments) {\n return urlSegments[1] || null;\n }\n\n const depth = contextSegments.length;\n return urlSegments[depth] || null;\n}\n\n/**\n * Pure function: compute all selected segments below a layout's depth.\n *\n * @param contextSegments — segments from root to the calling layout, or null if no context\n * @param pathname — current URL pathname\n * @returns all active segments below the layout\n */\nexport function getSelectedSegments(contextSegments: string[] | null, pathname: string): string[] {\n const urlSegments = pathnameToSegments(pathname);\n\n if (!contextSegments) {\n return urlSegments.slice(1).filter(Boolean);\n }\n\n const depth = contextSegments.length;\n return urlSegments.slice(depth).filter(Boolean);\n}\n\n/**\n * Returns the active child segment one level below the layout where this\n * hook is called. Returns `null` if the layout is the leaf (no child segment).\n *\n * Compatible with Next.js's `useSelectedLayoutSegment()` from `next/navigation`.\n *\n * @param parallelRouteKey — Optional parallel route key. Currently unused\n * (parallel route segment tracking is not yet implemented). Accepted for\n * API compatibility with Next.js.\n */\nexport function useSelectedLayoutSegment(parallelRouteKey?: string): string | null {\n void parallelRouteKey;\n const context = useSegmentContext();\n const pathname = usePathname();\n return getSelectedSegment(context?.segments ?? null, pathname);\n}\n\n/**\n * Returns all active segments below the layout where this hook is called.\n * Returns an empty array if the layout is the leaf (no child segments).\n *\n * Compatible with Next.js's `useSelectedLayoutSegments()` from `next/navigation`.\n *\n * @param parallelRouteKey — Optional parallel route key. Currently unused\n * (parallel route segment tracking is not yet implemented). Accepted for\n * API compatibility with Next.js.\n */\nexport function useSelectedLayoutSegments(parallelRouteKey?: string): string[] {\n void parallelRouteKey;\n const context = useSegmentContext();\n const pathname = usePathname();\n return getSelectedSegments(context?.segments ?? null, pathname);\n}\n","/**\n * Client-side form utilities for server actions.\n *\n * Exports a typed `useActionState` that understands the action builder's result shape.\n * Result is typed to:\n * { data: T } | { validationErrors: Record<string, string[]> } | { serverError: { code, data? } } | null\n *\n * The action builder emits a function that satisfies both the direct call signature\n * and React's `(prevState, formData) => Promise<State>` contract.\n *\n * See design/08-forms-and-actions.md §\"Client-Side Form Mechanics\"\n */\n\nimport { useActionState as reactUseActionState, useTransition } from 'react';\nimport type { ActionFn, ActionResult, InputHint, ValidationErrors } from '../server/action-client';\nimport type { FormFlashData } from '../server/form-flash';\n\n// ─── Types ───────────────────────────────────────────────────────────────\n\n/**\n * The action function type accepted by useActionState.\n * Must satisfy React's (prevState, formData) => Promise<State> contract.\n */\nexport type UseActionStateFn<TData> = (\n prevState: ActionResult<TData> | null,\n formData: FormData\n) => Promise<ActionResult<TData>>;\n\n/**\n * Return type of useActionState.\n * [result, formAction, isPending, errors]\n * The 4th element is auto-derived from result via useFormErrors logic.\n */\nexport type UseActionStateReturn<TData> = [\n result: ActionResult<TData> | null,\n formAction: (formData: FormData) => void,\n isPending: boolean,\n errors: FormErrorsResult,\n];\n\n// ─── useActionState ──────────────────────────────────────────────────────\n\n/**\n * Typed wrapper around React 19's `useActionState` that understands\n * the timber action builder's result shape.\n *\n * @param action - A server action created with createActionClient or a raw 'use server' function.\n * @param initialState - Initial state, typically `null`. Pass `getFormFlash()` for no-JS\n * progressive enhancement — the flash seeds the initial state so the form has a\n * single source of truth for both with-JS and no-JS paths.\n * @param permalink - Optional permalink for progressive enhancement (no-JS fallback URL).\n *\n * @example\n * ```tsx\n * 'use client'\n * import { useActionState } from '@timber-js/app/client'\n * import { createTodo } from './actions'\n *\n * export function NewTodoForm({ flash }) {\n * const [result, action, isPending] = useActionState(createTodo, flash)\n * return (\n * <form action={action}>\n * <input name=\"title\" />\n * {result?.validationErrors?.title && <p>{result.validationErrors.title}</p>}\n * <button disabled={isPending}>Add</button>\n * </form>\n * )\n * }\n * ```\n */\nexport function useActionState<TData>(\n action: UseActionStateFn<TData>,\n initialState: ActionResult<TData> | FormFlashData | null,\n permalink?: string\n): UseActionStateReturn<TData> {\n // FormFlashData is structurally compatible with ActionResult at runtime —\n // the cast satisfies React's generic inference which would otherwise widen TData.\n const [result, formAction, isPending] = reactUseActionState(\n action,\n initialState as ActionResult<TData> | null,\n permalink\n );\n const errors = deriveFormErrors(result);\n return [result, formAction, isPending, errors];\n}\n\n// ─── useFormAction ───────────────────────────────────────────────────────\n\n/**\n * Hook for calling a server action imperatively (not via a form).\n * Returns [execute, isPending] where execute accepts the input directly.\n *\n * @example\n * ```tsx\n * const [deleteTodo, isPending] = useFormAction(deleteTodoAction)\n * <button onClick={() => deleteTodo({ id: todo.id })} disabled={isPending}>\n * Delete\n * </button>\n * ```\n */\nexport function useFormAction<TData = unknown, TInput = unknown>(\n action: ActionFn<TData, TInput> | ((input: TInput) => Promise<ActionResult<TData>>)\n): [\n (\n ...args: undefined extends TInput ? [input?: InputHint<TInput>] : [input: InputHint<TInput>]\n ) => Promise<ActionResult<TData>>,\n boolean,\n] {\n const [isPending, startTransition] = useTransition();\n\n const execute = (input?: InputHint<TInput>): Promise<ActionResult<TData>> => {\n return new Promise((resolve) => {\n startTransition(async () => {\n const result = await (action as (input: InputHint<TInput>) => Promise<ActionResult<TData>>)(\n input as InputHint<TInput>\n );\n resolve(result);\n });\n });\n };\n\n return [execute, isPending];\n}\n\n// ─── Form error extraction ────────────────────────────────────────────────\n\n/** Return type of the errors element in useActionState. */\nexport interface FormErrorsResult {\n /** Per-field validation errors keyed by field name. */\n fieldErrors: Record<string, string[]>;\n /** Form-level errors (from `_root` key). */\n formErrors: string[];\n /** Server error if the action threw an ActionError. */\n serverError: { code: string; data?: Record<string, unknown> } | null;\n /** Whether any errors are present. */\n hasErrors: boolean;\n /** Get the first error message for a field, or null. */\n getFieldError: (field: string) => string | null;\n}\n\n/**\n * Derive FormErrorsResult from an action result.\n * Used internally by useActionState 4th tuple element.\n * @internal — exported for test access only.\n */\nexport function deriveFormErrors<TData>(\n result:\n | ActionResult<TData>\n | {\n validationErrors?: ValidationErrors;\n serverError?: { code: string; data?: Record<string, unknown> };\n }\n | null\n): FormErrorsResult {\n const empty: FormErrorsResult = {\n fieldErrors: {},\n formErrors: [],\n serverError: null,\n hasErrors: false,\n getFieldError: () => null,\n };\n\n if (!result) return empty;\n\n const validationErrors = result.validationErrors as ValidationErrors | undefined;\n const serverError = result.serverError as\n | { code: string; data?: Record<string, unknown> }\n | undefined;\n\n if (!validationErrors && !serverError) return empty;\n\n // Separate _root (form-level) errors from field errors\n const fieldErrors: Record<string, string[]> = {};\n const formErrors: string[] = [];\n\n if (validationErrors) {\n for (const [key, messages] of Object.entries(validationErrors)) {\n if (key === '_root') {\n formErrors.push(...messages);\n } else {\n fieldErrors[key] = messages;\n }\n }\n }\n\n const hasErrors =\n Object.keys(fieldErrors).length > 0 || formErrors.length > 0 || serverError != null;\n\n return {\n fieldErrors,\n formErrors,\n serverError: serverError ?? null,\n hasErrors,\n getFieldError(field: string): string | null {\n const errs = fieldErrors[field];\n return errs && errs.length > 0 ? errs[0] : null;\n },\n };\n}\n","// @timber-js/app/client — Client-side primitives\n// These are the primary imports for client components.\n//\n// Framework-internal bootstrap, SSR bridge, and segment plumbing is in\n// #client-internal (Node package import).\n// Design doc: design/triage/api-naming-spike.md §8.5\n\n// JsonSerializable moved to @timber-js/app/codec as the single canonical path.\n// Re-export removed per TIM-721.\nexport type { RenderErrorDigest } from './types';\n\n// Navigation\nexport { Link, interpolateParams, resolveHref, validateLinkHref, buildLinkProps } from './link';\nexport { mergePreservedSearchParams } from '../shared/merge-search-params.js';\nexport type { LinkProps, LinkPropsWithHref, LinkPropsWithParams, LinkBaseProps } from './link';\nexport type { LinkSegmentParams, OnNavigateHandler, OnNavigateEvent } from './link';\n\n// ─── LinkFunction (originally declared here for module augmentation) ───\n//\n// The codegen emits TWO augmentations of this interface:\n//\n// 1. Per-route call signatures — `href: '/products/[id]'` with direct\n// `segmentParams`/`searchParams` types.\n// 2. Catch-all call signatures — `ExternalHref` + computed `string`\n// variable hrefs.\n//\n// Both live in the codegen output (NOT here). Overload ORDER matters\n// for error UX, and TypeScript's declaration-merging rule \"later overload\n// sets ordered first\" means that whichever augmentation block is\n// declared LATER in the codegen file ends up FIRST in the merged call-\n// signature list — and therefore the EARLIER block ends up LAST, which\n// is the overload whose error message TypeScript reports when no\n// overload matches.\n//\n// We want the PER-ROUTE overloads to be the error-reporting ones when\n// a user passes a known literal `href` with a mistyped segmentParam.\n// So the codegen emits them FIRST and the catch-all SECOND. That way\n// a call like:\n//\n// <Link href=\"/products/[id]\" segmentParams={{ id: maybeUndef }} />\n//\n// reports the actual mismatch (`'string | undefined' is not assignable\n// to 'string | number'` on `id`) instead of the notorious \"Type 'string'\n// is not assignable to type 'never'\" cascade that the old fixed\n// catch-all produced (TIM-832, sibling of TIM-830 / TIM-624).\n//\n// The interface is kept as an empty placeholder here so:\n// - module augmentation has a base interface to merge into\n// - `link.tsx` can `import type { LinkFunction }` and use it as the\n// cast target for the `Link` const (an empty interface accepts any\n// non-nullable value, so the cast is valid even without codegen run)\n// - NO call signatures live here — in a fresh checkout where codegen\n// hasn't run yet, invoking `<Link>` is deliberately a type error,\n// which surfaces \"codegen hasn't run\" rather than a misleading\n// structural type mismatch.\n//\n// See TIM-624 (original typed Link), TIM-830 (flat searchParams), and\n// TIM-832 (this restructure for overload error UX).\nexport interface LinkFunction {\n // intentionally empty — call signatures are provided via codegen\n // augmentation in `.timber/timber-routes.d.ts`.\n}\nexport { usePendingNavigation } from './use-pending-navigation';\nexport { useLinkStatus, LinkStatusContext } from './use-link-status';\nexport type { LinkStatus } from './use-link-status';\nexport { useRouter } from './use-router';\nexport type { AppRouterInstance } from './use-router';\nexport { usePathname } from './use-pathname';\n// useSearchParams removed from public exports — lives in next/navigation shim only.\nexport { useSelectedLayoutSegment, useSelectedLayoutSegments } from './use-selected-layout-segment';\n\n// Forms\nexport { useActionState, useFormAction } from './form';\nexport type { UseActionStateFn, UseActionStateReturn, FormErrorsResult } from './form';\n\n// Params — raw hook for untyped access. For typed params, use defineSegmentParams().useSegmentParams().\nexport { useSegmentParams } from './use-segment-params';\n\n// Query states — useQueryStates lives on defineSearchParams().useQueryStates()\n// and is available via direct import for advanced cases.\n\n// Cookies — useCookie lives on defineCookie().useCookie().\n// Types still exported for advanced use.\nexport type { ClientCookieOptions, CookieSetter } from './use-cookie';\n\n// Register the client useSegmentParams hook with defineSegmentParams.\nimport { useSegmentParams as _useSegmentParams } from './use-segment-params.js';\nimport { _registerUseSegmentParams } from '../segment-params/define.js';\n_registerUseSegmentParams(_useSegmentParams);\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/client/use-link-status.ts","../../src/client/nav-link-store.ts","../../src/client/navigation-api.ts","../../src/client/link.tsx","../../src/client/use-pending-navigation.ts","../../src/client/use-router.ts","../../src/client/use-pathname.ts","../../src/client/use-selected-layout-segment.ts","../../src/client/form.tsx","../../src/client/index.ts"],"sourcesContent":["'use client';\n\n// useLinkStatus — returns { isPending: true } while the nearest parent <Link>'s\n// navigation is in flight. No arguments — scoped via React context.\n// See design/19-client-navigation.md §\"useLinkStatus()\"\n\nimport { useContext, createContext } from 'react';\n\nexport interface LinkStatus {\n isPending: boolean;\n}\n\n/**\n * React context provided by <Link>. Holds the pending status\n * for that specific link's navigation.\n */\nexport const LinkStatusContext = createContext<LinkStatus>({ isPending: false });\n\n/**\n * Returns `{ isPending: true }` while the nearest parent `<Link>` component's\n * navigation is in flight. Must be used inside a `<Link>` component's children.\n *\n * Unlike `usePendingNavigation()` which is global, this hook is scoped to\n * the nearest parent `<Link>` — only the link the user clicked shows pending.\n *\n * ```tsx\n * 'use client'\n * import { Link, useLinkStatus } from '@timber-js/app/client'\n *\n * function Hint() {\n * const { isPending } = useLinkStatus()\n * return <span className={isPending ? 'opacity-50' : ''} />\n * }\n *\n * export function NavLink({ href, children }) {\n * return (\n * <Link href={href}>\n * {children} <Hint />\n * </Link>\n * )\n * }\n * ```\n */\nexport function useLinkStatus(): LinkStatus {\n return useContext(LinkStatusContext);\n}\n","/**\n * Navigation Link Store — passes per-link metadata from Link's onClick\n * to the Navigation API's navigate event handler.\n *\n * When the Navigation API is active, Link does NOT call event.preventDefault()\n * or router.navigate(). Instead it stores metadata (scroll option, link\n * pending instance) here, and lets the <a> click propagate naturally.\n * The navigate event handler reads this metadata to configure the RSC\n * navigation with the correct options.\n *\n * This store is consumed once per navigation — after reading, the metadata\n * is cleared. If no metadata is present (e.g., a plain <a> tag without\n * our Link component), the navigate handler uses default options.\n *\n * See design/19-client-navigation.md §\"Navigation API Integration\"\n */\n\nimport type { LinkPendingInstance } from './link-pending-store.js';\n\nexport interface NavLinkMetadata {\n /** Whether to scroll to top after navigation. Default: true. */\n scroll: boolean;\n /** The Link's pending state instance for per-link status tracking. */\n linkInstance: LinkPendingInstance | null;\n}\n\nlet pendingMetadata: NavLinkMetadata | null = null;\n\n/**\n * Store metadata from Link's onClick for the next navigate event.\n * Called synchronously in the click handler — the navigate event\n * fires synchronously after onClick returns.\n */\nexport function setNavLinkMetadata(metadata: NavLinkMetadata): void {\n pendingMetadata = metadata;\n}\n\n/**\n * Consume the stored metadata. Returns null if no Link onClick\n * preceded this navigation (e.g., plain <a> tag, programmatic nav).\n * Clears the store after reading.\n */\nexport function consumeNavLinkMetadata(): NavLinkMetadata | null {\n const metadata = pendingMetadata;\n pendingMetadata = null;\n return metadata;\n}\n","/**\n * Navigation API integration — progressive enhancement for client navigation.\n *\n * When the Navigation API (`window.navigation`) is available, this module\n * provides an intercept-based navigation model that replaces the separate\n * popstate + click handler approach with a single navigate event listener.\n *\n * Key benefits:\n * - Intercepts ALL navigations (link clicks, form submissions, back/forward)\n * - Built-in AbortSignal per navigation (auto-aborts in-flight fetches)\n * - Per-entry state via NavigationHistoryEntry.getState()\n * - navigation.transition for progress tracking\n *\n * When unavailable, all functions are no-ops and the History API fallback\n * in browser-entry.ts handles navigation.\n *\n * See design/19-client-navigation.md\n */\n\nimport type { NavigationApi, NavigateEvent } from './navigation-api-types.js';\nimport { consumeNavLinkMetadata } from './nav-link-store.js';\nimport { isHardNavigating } from './navigation-root.js';\n\n// ─── Feature Detection ───────────────────────────────────────────\n\n/**\n * Returns true if the Navigation API is available in the current environment.\n * Feature-detected at runtime — no polyfill.\n */\nexport function hasNavigationApi(): boolean {\n return (\n typeof window !== 'undefined' &&\n 'navigation' in window &&\n (window as unknown as { navigation: unknown }).navigation != null\n );\n}\n\n/**\n * Get the Navigation API instance. Returns null if unavailable.\n * Uses type assertion — we never import Navigation API types unconditionally.\n */\nexport function getNavigationApi(): NavigationApi | null {\n if (!hasNavigationApi()) return null;\n return (window as unknown as { navigation: NavigationApi }).navigation;\n}\n\n// ─── Navigation API Controller ───────────────────────────────────\n\n/**\n * Callbacks for the Navigation API event handler.\n *\n * When the Navigation API intercepts a navigation, it delegates to these\n * callbacks which run the RSC fetch + render pipeline.\n */\nexport interface NavigationApiCallbacks {\n /**\n * Handle a push/replace navigation intercepted by the Navigation API.\n * This covers both Link <a> clicks (user-initiated, with metadata from\n * nav-link-store) and external navigations (plain <a> tags, programmatic).\n * The Navigation API handles the URL update via event.intercept().\n */\n onExternalNavigate: (\n url: string,\n options: { replace: boolean; signal: AbortSignal; scroll?: boolean }\n ) => Promise<void>;\n\n /**\n * Handle a traversal (back/forward button). The Navigation API intercepts\n * the traversal and delegates to us for RSC replay/fetch.\n */\n onTraverse: (url: string, scrollY: number, signal: AbortSignal) => Promise<void>;\n}\n\n/**\n * Controller returned by setupNavigationApi. Provides methods to\n * coordinate between the router and the navigate event listener.\n */\nexport interface NavigationApiController {\n /**\n * Set the router-navigating flag. When `true`, the next navigate event\n * (from pushState/replaceState) is recognized as router-initiated. The\n * handler still intercepts it — but ties the browser's native loading\n * state to a deferred promise instead of running the RSC pipeline again.\n *\n * This means `navigation.transition` is active for the full duration of\n * every router-initiated navigation, giving the browser a native loading\n * indicator (tab spinner, address bar) aligned with the TopLoader.\n *\n * Must be called synchronously around pushState/replaceState:\n * controller.setRouterNavigating(true);\n * history.pushState(...); // navigate event fires, intercepted\n * controller.setRouterNavigating(false); // flag off, deferred stays open\n */\n setRouterNavigating: (value: boolean) => void;\n\n /**\n * Resolve the deferred promise created by setRouterNavigating(true),\n * clearing the browser's native loading state. Call this when the\n * navigation fully completes — aligned with when the TopLoader's\n * pendingUrl clears (same finally block in router.navigate).\n */\n completeRouterNavigation: () => void;\n\n /**\n * Initiate a navigation via the Navigation API (`navigation.navigate()`).\n * Unlike `history.pushState()`, this fires the navigate event BEFORE\n * committing the URL — allowing Chrome to show its native loading\n * indicator while the intercept handler runs.\n *\n * Must be called with setRouterNavigating(true) active so the handler\n * recognizes it as router-initiated and uses the deferred promise.\n */\n navigate: (url: string, replace: boolean) => void;\n\n /**\n * Save scroll position into the current navigation entry's state.\n * Uses navigation.updateCurrentEntry() for per-entry scroll storage.\n */\n saveScrollPosition: (scrollY: number) => void;\n\n /**\n * Check if the Navigation API has an active transition.\n * Returns the transition object if available, null otherwise.\n */\n hasActiveTransition: () => boolean;\n\n /** Remove the navigate event listener. */\n cleanup: () => void;\n}\n\n/**\n * Set up the Navigation API navigate event listener.\n *\n * Intercepts same-origin navigations and delegates to the provided callbacks.\n * Router-initiated navigations (pushState from router.navigate) are detected\n * via a synchronous flag and NOT intercepted — the router already handles them.\n *\n * Returns a controller for coordinating with the router.\n */\nexport function setupNavigationApi(callbacks: NavigationApiCallbacks): NavigationApiController {\n const nav = getNavigationApi()!;\n\n let routerNavigating = false;\n\n // Deferred promise for router-initiated navigations. Created when\n // setRouterNavigating(true) is called, resolved by completeRouterNavigation().\n // The navigate event handler intercepts with this promise so the browser's\n // native loading state (tab spinner) stays active until the navigation\n // completes — aligned with TopLoader's pendingUrl lifecycle.\n let routerNavDeferred: { promise: Promise<void>; resolve: () => void } | null = null;\n\n function handleNavigate(event: NavigateEvent): void {\n // Skip non-interceptable navigations (cross-origin, etc.)\n if (!event.canIntercept) return;\n\n // Hard navigation guard: when the router has triggered a full page\n // load (500 error, version skew), skip interception entirely so the\n // browser performs the MPA navigation. Without this guard, setting\n // window.location.href fires a navigate event that we'd intercept,\n // running the RSC pipeline again → 500 → window.location.href →\n // navigate event → infinite loop.\n // See design/19-client-navigation.md §\"Hard Navigation Guard\"\n if (isHardNavigating()) return;\n\n // Skip download requests\n if (event.downloadRequest) return;\n\n // Skip hash-only changes — let the browser handle scroll-to-anchor\n if (event.hashChange) return;\n\n // Shallow URL updates (e.g., nuqs search param changes). The navigation\n // only changes the URL — no server round trip needed. Intercept with a\n // no-op handler so the Navigation API commits the URL change without\n // triggering a full page navigation (which is the default if we don't\n // intercept). The info property is the Navigation API's built-in\n // per-navigation metadata — no side-channel flags needed.\n const info = event.info as { shallow?: boolean } | null | undefined;\n if (info?.shallow) {\n event.intercept({\n handler: () => Promise.resolve(),\n focusReset: 'manual',\n scroll: 'manual',\n });\n return;\n }\n\n // Skip form submissions with a body (POST/PUT/etc.). These need the\n // browser's native form handling to send the request body to the server.\n // Intercepting would convert them into GET RSC navigations, dropping\n // the form data. Server actions use fetch() directly (not form navigation),\n // so they are unaffected by this check.\n if (event.formData) return;\n\n // Skip cross-origin (defense-in-depth — canIntercept covers this)\n const destUrl = new URL(event.destination.url);\n if (destUrl.origin !== location.origin) return;\n\n // Router-initiated navigation (Link click → router.navigate → pushState).\n // The router is already running the RSC pipeline — don't run it again.\n // Instead, intercept with the deferred promise so the browser's native\n // loading state tracks the navigation's full lifecycle. This aligns the\n // tab spinner / address bar indicator with the TopLoader.\n if (routerNavigating && routerNavDeferred) {\n event.intercept({\n scroll: 'manual',\n focusReset: 'manual',\n handler: () => routerNavDeferred!.promise,\n });\n return;\n }\n\n // Skip reload navigations — let the browser handle full page reload\n if (event.navigationType === 'reload') return;\n\n const url = destUrl.pathname + destUrl.search;\n\n if (event.navigationType === 'traverse') {\n // Back/forward button — intercept and delegate to router.\n // Read scroll position from the destination entry's state.\n const entryState = event.destination.getState() as\n | { scrollY?: number; timber?: boolean }\n | null\n | undefined;\n const scrollY = entryState && typeof entryState.scrollY === 'number' ? entryState.scrollY : 0;\n\n event.intercept({\n // Manual scroll — we handle scroll restoration ourselves\n // via afterPaint (same as the History API path).\n scroll: 'manual',\n focusReset: 'manual',\n async handler() {\n await callbacks.onTraverse(url, scrollY, event.signal);\n },\n });\n } else if (event.navigationType === 'push' || event.navigationType === 'replace') {\n // Push/replace — either a Link <a> click (with metadata in\n // nav-link-store) or an external navigation (plain <a>, programmatic).\n // Consume link metadata if present — tells us scroll preference\n // and which Link component to track pending state for.\n const linkMeta = consumeNavLinkMetadata();\n\n // Save the departing page's scroll position BEFORE event.intercept()\n // commits the URL change. Once intercept() is called, currentEntry\n // switches to the new (destination) entry — any updateCurrentEntry()\n // call after that would save to the wrong entry.\n // See: router.navigate() also calls saveNavigationEntryScroll(), but\n // for Navigation API <a> click navigations (where Link does NOT call\n // router.navigate directly), the router's save runs inside the\n // intercept handler — too late, currentEntry has already switched.\n try {\n const currentState = (nav.currentEntry?.getState() ?? {}) as Record<string, unknown>;\n nav.updateCurrentEntry({\n state: { ...currentState, timber: true, scrollY: window.scrollY },\n });\n } catch {\n // Ignore — entry may be disposed\n }\n\n event.intercept({\n scroll: 'manual',\n focusReset: 'manual',\n async handler() {\n await callbacks.onExternalNavigate(url, {\n replace: event.navigationType === 'replace',\n signal: event.signal,\n scroll: linkMeta?.scroll,\n });\n },\n });\n }\n }\n\n nav.addEventListener('navigate', handleNavigate as EventListener);\n\n return {\n setRouterNavigating(value: boolean): void {\n routerNavigating = value;\n if (value) {\n // Create a new deferred promise. The navigate event handler will\n // intercept and tie the browser's loading state to this promise.\n let resolve!: () => void;\n const promise = new Promise<void>((r) => {\n resolve = r;\n });\n routerNavDeferred = { promise, resolve };\n } else {\n // Flag off — but DON'T resolve the deferred here. The navigation\n // is still in flight (RSC fetch + render). completeRouterNavigation()\n // resolves it when the navigation fully completes.\n routerNavigating = false;\n }\n },\n\n completeRouterNavigation(): void {\n if (routerNavDeferred) {\n routerNavDeferred.resolve();\n routerNavDeferred = null;\n }\n },\n\n navigate(url: string, replace: boolean): void {\n // Use navigation.navigate() instead of history.pushState().\n // This fires the navigate event BEFORE committing the URL,\n // which lets Chrome show its native loading indicator while\n // the intercept handler (deferred promise) is pending.\n // history.pushState() commits the URL synchronously, so Chrome\n // sees the navigation as already complete and skips the indicator.\n nav.navigate(url, {\n history: replace ? 'replace' : 'push',\n });\n },\n\n saveScrollPosition(scrollY: number): void {\n try {\n const currentState = (nav.currentEntry?.getState() ?? {}) as Record<string, unknown>;\n nav.updateCurrentEntry({\n state: { ...currentState, timber: true, scrollY },\n });\n } catch {\n // Ignore errors — updateCurrentEntry may throw if entry is disposed\n }\n },\n\n hasActiveTransition(): boolean {\n return nav.transition != null;\n },\n\n cleanup(): void {\n nav.removeEventListener('navigate', handleNavigate as EventListener);\n },\n };\n}\n","'use client';\n\n// Link component — client-side navigation with progressive enhancement\n// See design/19-client-navigation.md § Progressive Enhancement\n//\n// Without JavaScript, <Link> renders as a plain <a> tag — standard browser\n// navigation. With JavaScript, the Link component's onClick handler triggers\n// RSC-based client navigation via the router.\n//\n// Each Link owns its own click handler — no global event delegation.\n// This keeps navigation within React's component tree, ensuring pending\n// state (useLinkStatus) updates atomically with the navigation.\n//\n// Typed Link: design/09-typescript.md §\"Typed Link\"\n// - href validated against known routes (via codegen overloads, not runtime)\n// - params prop typed per-route, URL interpolated at runtime\n// - searchParams prop serialized via SearchParamsDefinition\n// - params and fully-resolved string href are mutually exclusive\n// - searchParams and inline query string are mutually exclusive\n\nimport {\n useOptimistic,\n useEffect,\n useRef,\n type AnchorHTMLAttributes,\n type ReactNode,\n type MouseEvent as ReactMouseEvent,\n} from 'react';\nimport type { SearchParamsDefinition } from '../search-params/define.js';\nimport { getSearchParamsDefinition } from '../search-params/registry.js';\nimport type { LinkFunction } from './index.js';\nimport { classifyUrlSegment, type UrlSegment } from '../routing/segment-classify.js';\nimport { LinkStatusContext } from './use-link-status.js';\nimport { getRouterOrNull } from './router-ref.js';\nimport { getSsrData } from './ssr-data.js';\nimport { mergePreservedSearchParams } from '../shared/merge-search-params.js';\nimport {\n setLinkForCurrentNavigation,\n unmountLinkForCurrentNavigation,\n LINK_IDLE,\n type LinkPendingInstance,\n} from './link-pending-store.js';\nimport { setNavLinkMetadata } from './nav-link-store.js';\nimport { hasNavigationApi } from './navigation-api.js';\nimport { getLinkCodec } from '../params/codec-registry.js';\n\n// ─── Current Search Params ────────────────────────────────────────\n\n/**\n * Read the current URL's search string without requiring a React hook.\n * On the client, reads window.location.search. During SSR, reads from\n * the request context (getSsrData). Returns empty string if unavailable.\n */\nfunction getCurrentSearch(): string {\n if (typeof window !== 'undefined') return window.location.search;\n const data = getSsrData();\n if (!data) return '';\n const sp = new URLSearchParams(data.searchParams);\n const str = sp.toString();\n return str ? `?${str}` : '';\n}\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport type OnNavigateEvent = {\n preventDefault: () => void;\n};\n\nexport type OnNavigateHandler = (e: OnNavigateEvent) => void;\n\n/**\n * Base props shared by all Link variants.\n *\n * Exported so the public `LinkFunction` interface (declared in\n * `./index.ts`, where module augmentation can merge into it) can\n * compose this without duplication.\n */\nexport interface LinkBaseProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {\n /** Prefetch the RSC payload on hover */\n prefetch?: boolean;\n /**\n * Scroll to top on navigation. Defaults to true.\n * Set to false for tabbed interfaces where content changes within a fixed layout.\n */\n scroll?: boolean;\n /**\n * Preserve search params from the current URL across navigation.\n *\n * - `true` — preserve ALL current search params (target params take precedence)\n * - `string[]` — preserve only the named params (e.g. `['private', 'token']`)\n *\n * Useful for route-group gating where a search param (e.g. `?private=access`)\n * must persist across internal navigations. The target href's own search params\n * always take precedence over preserved ones.\n *\n * During SSR, reads search params from the request context. On the client,\n * reads from the current URL and updates reactively when the URL changes.\n */\n preserveSearchParams?: true | string[];\n /**\n * Called before client-side navigation commits. Call `e.preventDefault()`\n * to cancel the default navigation — the caller is then responsible for\n * navigating (e.g. via `router.push()`).\n *\n * Only fires for client-side SPA navigations, not full page loads.\n * Has no effect during SSR.\n */\n onNavigate?: OnNavigateHandler;\n children?: ReactNode;\n}\n\n// ─── Typed Link Props ────────────────────────────────────────────\n\n/**\n * Widen server-side string params to string | number for Link convenience.\n * Exported for use by codegen-generated overloads.\n */\nexport type LinkSegmentParams<T> = {\n [K in keyof T]: [string] extends [T[K]] ? string | number : T[K];\n};\n\n// ─── External Href Types ─────────────────────────────────────────\n//\n// `ExternalHref` and the public `LinkFunction` interface live in\n// `./index.ts` rather than this file. They MUST be originally declared\n// in the same module that the codegen augments (`@timber-js/app/client`)\n// so that codegen-generated per-route call signatures merge with the\n// same interface that types the `Link` constant. Re-exporting an\n// interface via `export type {}` does NOT participate in module\n// augmentation merging — only originally-declared interfaces do.\n// See TIM-624.\n\n// ─── searchParams prop shapes ────────────────────────────────────\n//\n// Per-route Link overloads (emitted by codegen) pass the flat values shape:\n// searchParams={{ page: 2, q: 'boots' }}\n// The framework looks up the route's SearchParamsDefinition from the\n// search-params registry at runtime (see TIM-830).\n//\n// The catch-all overload in client/index.ts (external/computed hrefs)\n// additionally accepts the legacy wrapped shape:\n// searchParams={{ definition: def, values: { page: 2 } }}\n// because there is no way to look up a definition from a computed string.\n//\n// `resolveHref` discriminates at runtime by presence of a `definition` key.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ParamValue = string | number | string[] | { toString(): string; [key: string]: any };\n\ntype WrappedSearchParamsProp = {\n definition: SearchParamsDefinition<Record<string, unknown>>;\n values: Record<string, unknown>;\n};\ntype FlatSearchParamsProp = Record<string, unknown>;\ntype LinkSearchParamsProp = WrappedSearchParamsProp | FlatSearchParamsProp;\n\n/**\n * Runtime-only loose props used internally by the Link implementation.\n * Not exposed to callers — the public API uses LinkFunction.\n */\ninterface LinkRuntimeProps extends LinkBaseProps {\n href: string;\n segmentParams?: Record<string, ParamValue>;\n searchParams?: LinkSearchParamsProp;\n}\n\n// Legacy exports for backward compat (used by buildLinkProps, tests, etc.)\nexport type LinkPropsWithHref = LinkBaseProps & {\n href: string;\n segmentParams?: never;\n searchParams?: LinkSearchParamsProp;\n};\nexport type LinkPropsWithParams = LinkRuntimeProps & {\n segmentParams: Record<string, ParamValue>;\n};\nexport type LinkProps = LinkRuntimeProps;\n\n// ─── Dangerous URL Scheme Detection ──────────────────────────────\n\n/**\n * Reject dangerous URL schemes that could execute script.\n * Security: design/13-security.md § Link scheme injection (test #9)\n */\nconst DANGEROUS_SCHEMES = /^\\s*(javascript|data|vbscript):/i;\n\nexport function validateLinkHref(href: string): void {\n if (DANGEROUS_SCHEMES.test(href)) {\n throw new Error(\n `<Link> received a dangerous href: \"${href}\". ` +\n 'javascript:, data:, and vbscript: URLs are not allowed.'\n );\n }\n}\n\n// ─── Internal Link Detection ─────────────────────────────────────\n\n/** Returns true if the href is an internal path (not an external URL) */\nexport function isInternalHref(href: string): boolean {\n // Relative paths, root-relative paths, and hash links are internal\n if (href.startsWith('/') || href.startsWith('#') || href.startsWith('?')) {\n return true;\n }\n // Anything with a protocol scheme is external\n if (/^[a-z][a-z0-9+.-]*:/i.test(href)) {\n return false;\n }\n // Bare relative paths (e.g., \"dashboard\") are internal\n return true;\n}\n\n// ─── URL Interpolation ──────────────────────────────────────────\n\n/**\n * Interpolate dynamic segments in a route pattern with actual values.\n * e.g. interpolateParams(\"/products/[id]\", { id: \"123\" }) → \"/products/123\"\n *\n * Supports:\n * - [param] → single segment\n * - [...param] → catch-all (joined with /)\n * - [[...param]] → optional catch-all (omitted if undefined/empty)\n */\n/**\n * Parse a route pattern's path portion into classified segments.\n * Exported for testing. Uses the shared character-based classifier.\n */\nexport function parseSegments(pattern: string): UrlSegment[] {\n return pattern.split('/').filter(Boolean).map(classifyUrlSegment);\n}\n\n/**\n * Resolve a single classified segment into its string representation.\n * Returns null for optional catch-all with no value (filtered out before join).\n *\n * When schema codecs are registered (via virtual:timber-schema), uses\n * codec.serialize() for URL construction instead of plain String().\n */\nfunction resolveSegment(\n seg: UrlSegment,\n params: Record<string, ParamValue>,\n pattern: string\n): string | null {\n switch (seg.kind) {\n case 'static':\n return seg.value;\n\n case 'optional-catch-all': {\n const value = params[seg.name];\n if (value === undefined || (Array.isArray(value) && value.length === 0)) {\n return null;\n }\n const codec = getLinkCodec(`[[...${seg.name}]]`);\n if (codec) {\n const serialized = codec.serialize(value);\n if (!serialized) return null;\n // Codec returns a path string (may contain '/') — encode each segment\n return serialized.split('/').map(encodeURIComponent).join('/');\n }\n const segments = Array.isArray(value) ? value : [value];\n return segments.map(encodeURIComponent).join('/');\n }\n\n case 'catch-all': {\n const value = params[seg.name];\n if (value === undefined) {\n throw new Error(\n `<Link> missing required catch-all param \"${seg.name}\" for pattern \"${pattern}\".`\n );\n }\n const codec = getLinkCodec(`[...${seg.name}]`);\n if (codec) {\n const serialized = codec.serialize(value);\n if (!serialized) {\n throw new Error(\n `<Link> catch-all param \"${seg.name}\" codec returned null for pattern \"${pattern}\".`\n );\n }\n // Codec returns a path string (may contain '/') — encode each segment\n return serialized.split('/').map(encodeURIComponent).join('/');\n }\n const segments = Array.isArray(value) ? value : [value];\n if (segments.length === 0) {\n throw new Error(\n `<Link> catch-all param \"${seg.name}\" must have at least one segment for pattern \"${pattern}\".`\n );\n }\n return segments.map(encodeURIComponent).join('/');\n }\n\n case 'dynamic': {\n const value = params[seg.name];\n if (value === undefined) {\n throw new Error(`<Link> missing required param \"${seg.name}\" for pattern \"${pattern}\".`);\n }\n if (Array.isArray(value)) {\n throw new Error(\n `<Link> param \"${seg.name}\" expected a string but received an array for pattern \"${pattern}\".`\n );\n }\n const codec = getLinkCodec(`[${seg.name}]`);\n const str = codec ? (codec.serialize(value) ?? String(value)) : String(value);\n return encodeURIComponent(str);\n }\n }\n}\n\n/**\n * Split a URL pattern into the path portion and any trailing ?query/#hash suffix.\n * Uses URL parsing for correctness rather than manual index arithmetic.\n */\nfunction splitPatternSuffix(pattern: string): [path: string, suffix: string] {\n if (!pattern.includes('?') && !pattern.includes('#')) {\n return [pattern, ''];\n }\n const url = new URL(pattern, 'http://x');\n const suffix = url.search + url.hash;\n const path = pattern.slice(0, pattern.length - suffix.length);\n return [path, suffix];\n}\n\nexport function interpolateParams(pattern: string, params: Record<string, ParamValue>): string {\n const [pathPart, suffix] = splitPatternSuffix(pattern);\n\n const resolved = parseSegments(pathPart)\n .map((seg) => resolveSegment(seg, params, pattern))\n .filter((s): s is string => s !== null);\n return ('/' + resolved.join('/') || '/') + suffix;\n}\n\n// ─── Resolve Href ───────────────────────────────────────────────\n\n/**\n * Resolve the final href string from Link props.\n *\n * Handles:\n * - params interpolation into route patterns\n * - searchParams serialization via SearchParamsDefinition\n * - Validation that searchParams and inline query strings are exclusive\n */\n/**\n * Runtime discriminator: treat `searchParams` as the legacy wrapped shape\n * only when it literally has a `definition` key. Everything else is the\n * flat `Partial<T>` values shape (TIM-830).\n */\nfunction isWrappedSearchParamsProp(sp: LinkSearchParamsProp): sp is WrappedSearchParamsProp {\n return 'definition' in sp;\n}\n\nexport function resolveHref(\n href: string,\n params?: Record<string, ParamValue>,\n searchParams?: LinkSearchParamsProp\n): string {\n let resolvedPath = href;\n\n // Interpolate params if provided\n if (params) {\n resolvedPath = interpolateParams(href, params);\n }\n\n // Serialize searchParams if provided\n if (searchParams) {\n // Validate: searchParams prop and inline query string are mutually exclusive\n if (resolvedPath.includes('?')) {\n throw new Error(\n '<Link> received both a searchParams prop and a query string in href. ' +\n 'These are mutually exclusive — use one or the other.'\n );\n }\n\n let definition: SearchParamsDefinition<Record<string, unknown>> | undefined;\n let values: Record<string, unknown>;\n\n if (isWrappedSearchParamsProp(searchParams)) {\n // Legacy wrapped shape — used by the catch-all overload for\n // computed/external hrefs where no route lookup is possible.\n definition = searchParams.definition;\n values = searchParams.values;\n } else {\n // Flat shape (TIM-830): look up the definition from the runtime\n // registry using the un-interpolated href pattern (e.g. '/products/[id]').\n // The search-params registry is populated eagerly at startup by the\n // virtual:timber-search-params-registry module generated by the\n // timber-routing Vite plugin.\n definition = getSearchParamsDefinition(href) as\n | SearchParamsDefinition<Record<string, unknown>>\n | undefined;\n values = searchParams;\n if (!definition) {\n throw new Error(\n `<Link> received a flat searchParams object for href \"${href}\", but no ` +\n `SearchParamsDefinition is registered for that route. ` +\n `Either the route does not export \\`searchParams\\` from its \\`params.ts\\`/\\`page.tsx\\`, ` +\n `or the search-params registry module was not loaded. ` +\n `For external or computed hrefs, pass the legacy \\`{ definition, values }\\` shape instead.`\n );\n }\n }\n\n const qs = definition.serialize(values);\n if (qs) {\n resolvedPath = `${resolvedPath}?${qs}`;\n }\n }\n\n return resolvedPath;\n}\n\n// ─── Build Props ─────────────────────────────────────────────────\n\ninterface LinkOutputProps {\n href: string;\n}\n\n/**\n * Build the HTML attributes for a Link. Separated from the component\n * for testability — the component just spreads these onto an <a>.\n */\nexport function buildLinkProps(\n props: Pick<LinkPropsWithHref, 'href'> & {\n params?: Record<string, ParamValue>;\n searchParams?: LinkSearchParamsProp;\n }\n): LinkOutputProps {\n const resolvedHref = resolveHref(props.href, props.params, props.searchParams);\n validateLinkHref(resolvedHref);\n return { href: resolvedHref };\n}\n\n// ─── Click Handler ───────────────────────────────────────────────\n\n/**\n * Should this click be intercepted for SPA navigation?\n *\n * Returns false (pass through to browser) when:\n * - Modified keys are held (Ctrl, Meta, Shift, Alt) — open in new tab\n * - The click is not the primary button\n * - The event was already prevented by a parent handler\n * - The link has target=\"_blank\" or similar\n * - The link has a download attribute\n * - The href is external\n */\nfunction shouldInterceptClick(\n event: ReactMouseEvent<HTMLAnchorElement>,\n resolvedHref: string\n): boolean {\n if (event.button !== 0) return false;\n if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;\n if (event.defaultPrevented) return false;\n\n const anchor = event.currentTarget;\n if (anchor.target && anchor.target !== '_self') return false;\n if (anchor.hasAttribute('download')) return false;\n\n if (!isInternalHref(resolvedHref)) return false;\n\n return true;\n}\n\n// ─── Link Component ──────────────────────────────────────────────\n\n/**\n * Navigation link with progressive enhancement.\n *\n * Renders as a plain `<a>` tag — works without JavaScript. When the client\n * runtime is active, the Link's onClick handler triggers RSC-based client\n * navigation via the router. No global event delegation — each Link owns\n * its own click handling.\n *\n * Supports typed routes via the Routes interface (populated by codegen).\n * At runtime:\n * - `segmentParams` prop interpolates dynamic segments in the href pattern\n * - `searchParams` prop serializes query parameters via a SearchParamsDefinition\n *\n * Typed via the LinkFunction callable interface. The base call signature\n * forbids segmentParams; per-route signatures are added by codegen via\n * interface merging. See TIM-624.\n */\n// Cast to LinkFunction — the callable interface provides the public type,\n// but the implementation destructures LinkRuntimeProps internally.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport const Link: LinkFunction = function LinkImpl(props: any) {\n const {\n href,\n prefetch,\n scroll,\n segmentParams,\n searchParams,\n preserveSearchParams,\n onNavigate,\n onClick: userOnClick,\n onMouseEnter: userOnMouseEnter,\n children,\n ...rest\n } = props as LinkRuntimeProps;\n const { href: baseHref } = buildLinkProps({ href, params: segmentParams, searchParams });\n\n // ─── Per-link pending state (useOptimistic) ────────────────────────\n // Each Link has its own pending state. Only the clicked link's\n // setter is invoked during navigation — zero other links re-render.\n //\n // Link click stores the instance; NavigationRoot activates inside startTransition.\n // useOptimistic auto-reverts to LINK_IDLE when the navigation\n // startTransition — batched with the new tree commit.\n //\n // See design/19-client-navigation.md §\"Per-Link Pending State\"\n const [linkStatus, setIsPending] = useOptimistic(LINK_IDLE);\n\n // Build the link instance ref for the pending store.\n // The ref is stable across renders — we update the setter on each\n // render to keep it current.\n const linkInstanceRef = useRef<LinkPendingInstance | null>(null);\n if (!linkInstanceRef.current) {\n linkInstanceRef.current = { setIsPending };\n } else {\n linkInstanceRef.current.setIsPending = setIsPending;\n }\n\n // Clean up if this link unmounts while it's the current navigation link.\n // Prevents calling setOptimistic on an unmounted component.\n useEffect(() => {\n const instance = linkInstanceRef.current;\n return () => {\n if (instance) {\n unmountLinkForCurrentNavigation(instance);\n }\n };\n }, []);\n\n // Preserve search params from the current URL when requested.\n // useSearchParams() works during both SSR (reads from request context)\n // and on the client (reads from window.location, reactive to URL changes).\n // We read current search params directly to avoid unconditional hook calls.\n // On the client, window.location.search is always current; during SSR,\n // getSsrData() provides the request's search params.\n const resolvedHref = preserveSearchParams\n ? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)\n : baseHref;\n\n const internal = isInternalHref(resolvedHref);\n\n // ─── Click handler ───────────────────────────────────────────\n // Each Link component owns its click handling. The router is\n // accessed via the singleton ref — during SSR, getRouterOrNull()\n // returns null and onClick is a no-op (the <a> works as a plain link).\n const handleClick = internal\n ? (event: ReactMouseEvent<HTMLAnchorElement>) => {\n // Call user's onClick first (e.g., analytics)\n userOnClick?.(event);\n\n if (!shouldInterceptClick(event, resolvedHref)) return;\n\n // Call onNavigate if provided — allows caller to cancel\n if (onNavigate) {\n let prevented = false;\n onNavigate({\n preventDefault: () => {\n prevented = true;\n },\n });\n if (prevented) {\n event.preventDefault();\n return;\n }\n }\n\n const router = getRouterOrNull();\n if (!router) return; // SSR or pre-hydration — fall through to browser nav\n\n const shouldScroll = scroll !== false;\n\n // Register this link in the pending store. The actual\n // setIsPending(LINK_PENDING) call happens inside NavigationRoot's\n // async startTransition via activateLinkPending().\n\n setLinkForCurrentNavigation(linkInstanceRef.current);\n\n // When Navigation API is active, let the <a> click propagate\n // naturally — do NOT call preventDefault(). The navigate event\n // handler intercepts it and runs the RSC pipeline. This is a\n // user-initiated navigation, so Chrome shows the native loading\n // indicator (tab spinner). Metadata (scroll, link instance) is\n // passed via nav-link-store so the handler can configure the nav.\n //\n // Without Navigation API (fallback), preventDefault and drive\n // navigation through the router as before.\n if (hasNavigationApi()) {\n setNavLinkMetadata({\n scroll: shouldScroll,\n linkInstance: linkInstanceRef.current,\n });\n // Don't preventDefault — let the <a> click fire the navigate event\n return;\n }\n\n // History API fallback — prevent default and navigate via router\n event.preventDefault();\n\n // Re-merge preserved search params at click time to pick up any\n // URL changes since render (e.g. from other navigations or pushState).\n const navHref = preserveSearchParams\n ? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)\n : resolvedHref;\n\n void router.navigate(navHref, { scroll: shouldScroll });\n }\n : userOnClick; // External links — just pass through user's onClick\n\n // ─── Hover prefetch ──────────────────────────────────────────\n const handleMouseEnter =\n internal && prefetch\n ? (event: ReactMouseEvent<HTMLAnchorElement>) => {\n userOnMouseEnter?.(event);\n const router = getRouterOrNull();\n if (router) {\n // Re-merge preserved search params at hover time for fresh prefetch URL\n const prefetchHref = preserveSearchParams\n ? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)\n : resolvedHref;\n router.prefetch(prefetchHref);\n }\n }\n : userOnMouseEnter;\n\n return (\n <a {...rest} href={resolvedHref} onClick={handleClick} onMouseEnter={handleMouseEnter}>\n <LinkStatusContext.Provider value={linkStatus}>{children}</LinkStatusContext.Provider>\n </a>\n );\n};\n","// usePendingNavigation — returns true while an RSC navigation is in flight.\n// See design/19-client-navigation.md §\"usePendingNavigation()\"\n//\n// Reads from PendingNavigationContext (provided by NavigationRoot) so the\n// pending state shows immediately (urgent update) and clears atomically\n// with the new tree (same startTransition commit).\n\nimport { usePendingNavigationUrl } from './navigation-context.js';\n\n/**\n * Returns true while an RSC navigation is in flight.\n *\n * The pending state is true from the moment the RSC fetch starts until\n * React reconciliation completes. This includes the fetch itself,\n * RSC stream parsing, and React tree reconciliation.\n *\n * It does NOT include Suspense streaming after the shell — only the\n * initial shell reconciliation.\n *\n * ```tsx\n * 'use client'\n * import { usePendingNavigation } from '@timber-js/app/client'\n *\n * export function NavBar() {\n * const isPending = usePendingNavigation()\n * return (\n * <nav className={isPending ? 'opacity-50' : ''}>\n * <Link href=\"/dashboard\">Dashboard</Link>\n * </nav>\n * )\n * }\n * ```\n */\nexport function usePendingNavigation(): boolean {\n const pendingUrl = usePendingNavigationUrl();\n // During SSR or outside PendingNavigationProvider, no navigation is pending\n return pendingUrl !== null;\n}\n","/**\n * useRouter() — client-side hook for programmatic navigation.\n *\n * Returns a router instance with push, replace, refresh, back, forward,\n * and prefetch methods. Compatible with Next.js's `useRouter()` from\n * `next/navigation` (App Router).\n *\n * This wraps timber's internal RouterInstance in the Next.js-compatible\n * AppRouterInstance shape that ecosystem libraries expect.\n *\n * NOTE: Unlike Next.js, these methods do NOT wrap navigation in\n * startTransition. In Next.js, router state is React state (useReducer)\n * so startTransition defers the update and provides isPending tracking.\n * In timber, navigation calls reactRoot.render() which is a root-level\n * render — startTransition has no effect on root renders.\n *\n * Navigation state (params, pathname) is delivered atomically via\n * NavigationContext embedded in the element tree passed to\n * reactRoot.render(). See design/19-client-navigation.md §\"NavigationContext\".\n *\n * For loading UI during navigation, use:\n * - useLinkStatus() — per-link pending indicator (inside <Link>)\n * - usePendingNavigation() — global navigation pending state\n */\n\nimport { getRouterOrNull } from './router-ref.js';\n\nexport interface AppRouterInstance {\n /** Navigate to a URL, pushing a new history entry */\n push(href: string, options?: { scroll?: boolean }): void;\n /** Navigate to a URL, replacing the current history entry */\n replace(href: string, options?: { scroll?: boolean }): void;\n /** Refresh the current page (re-fetch RSC payload) */\n refresh(): void;\n /** Navigate back in history */\n back(): void;\n /** Navigate forward in history */\n forward(): void;\n /** Prefetch an RSC payload for a URL */\n prefetch(href: string): void;\n}\n\n/**\n * Get a router instance for programmatic navigation.\n *\n * Compatible with Next.js's `useRouter()` from `next/navigation`.\n *\n * Methods lazily resolve the global router when invoked (during user\n * interaction) rather than capturing it at render time. This is critical\n * because during hydration, React synchronously executes component render\n * functions *before* the router is bootstrapped in browser-entry.ts.\n * If we eagerly captured the router during render, components would get\n * a null reference and be stuck with silent no-ops forever.\n *\n * Returns safe no-ops during SSR or before bootstrap. The `typeof window`\n * check is insufficient because Vite's client SSR environment defines\n * `window`, so we use a try/catch on getRouter() — but only at method\n * invocation time, not at render time.\n */\nexport function useRouter(): AppRouterInstance {\n return {\n push(href: string, options?: { scroll?: boolean }) {\n const router = getRouterOrNull();\n if (!router) {\n if (process.env.NODE_ENV === 'development') {\n console.error(\n '[timber] useRouter().push() called but router is not initialized. This is a bug — please report it.'\n );\n }\n return;\n }\n void router.navigate(href, { scroll: options?.scroll });\n },\n replace(href: string, options?: { scroll?: boolean }) {\n const router = getRouterOrNull();\n if (!router) {\n if (process.env.NODE_ENV === 'development') {\n console.error('[timber] useRouter().replace() called but router is not initialized.');\n }\n return;\n }\n void router.navigate(href, { scroll: options?.scroll, replace: true });\n },\n refresh() {\n const router = getRouterOrNull();\n if (!router) {\n if (process.env.NODE_ENV === 'development') {\n console.error('[timber] useRouter().refresh() called but router is not initialized.');\n }\n return;\n }\n void router.refresh();\n },\n back() {\n if (typeof window !== 'undefined') window.history.back();\n },\n forward() {\n if (typeof window !== 'undefined') window.history.forward();\n },\n prefetch(href: string) {\n const router = getRouterOrNull();\n if (!router) return; // Silent — prefetch failure is non-fatal\n router.prefetch(href);\n },\n };\n}\n","/**\n * usePathname() — client-side hook for reading the current pathname.\n *\n * Returns the pathname portion of the current URL (e.g. '/dashboard/settings').\n * Updates when client-side navigation changes the URL.\n *\n * On the client, reads from NavigationContext which is updated atomically\n * with the RSC tree render. This replaces the previous useSyncExternalStore\n * approach which only subscribed to popstate events — meaning usePathname()\n * did NOT re-render on forward navigation (pushState). The context approach\n * fixes this: pathname updates in the same render pass as the new tree.\n *\n * During SSR, reads the request pathname from the SSR ALS context\n * (populated by ssr-entry.ts) instead of window.location.\n *\n * Compatible with Next.js's `usePathname()` from `next/navigation`.\n */\n\nimport { getSsrData } from './ssr-data.js';\nimport { useNavigationContext } from './navigation-context.js';\n\n/**\n * Read the current URL pathname.\n *\n * On the client, reads from NavigationContext (provided by\n * NavigationProvider in renderRoot). During SSR, reads from the\n * ALS-backed SSR data context. Falls back to window.location.pathname\n * when called outside a React component (e.g., in tests).\n */\nexport function usePathname(): string {\n // Try reading from NavigationContext (client-side, inside React tree).\n // During SSR, no NavigationProvider is mounted, so this returns null.\n try {\n const navContext = useNavigationContext();\n if (navContext !== null) {\n return navContext.pathname;\n }\n } catch {\n // No React dispatcher available (called outside a component).\n // Fall through to SSR/fallback below.\n }\n\n // SSR path: read from ALS-backed SSR data context.\n const ssrData = getSsrData();\n if (ssrData) return ssrData.pathname ?? '/';\n\n // Final fallback: window.location (tests, edge cases).\n if (typeof window !== 'undefined') return window.location.pathname;\n return '/';\n}\n","/**\n * useSelectedLayoutSegment / useSelectedLayoutSegments — client-side hooks\n * for reading the active segment(s) below the current layout.\n *\n * These hooks are used by navigation UIs to highlight active sections.\n * They match Next.js's API from next/navigation.\n *\n * How they work:\n * 1. Each layout is wrapped with a SegmentProvider that records its depth\n * (the URL segments from root to that layout level).\n * 2. The hooks read the current URL pathname via usePathname().\n * 3. They compare the layout's segment depth against the full URL segments\n * to determine which child segments are \"selected\" below.\n *\n * Example: For URL \"/dashboard/settings/profile\"\n * - Root layout (depth 0, segments: ['']): selected segment = \"dashboard\"\n * - Dashboard layout (depth 1, segments: ['', 'dashboard']): selected = \"settings\"\n * - Settings layout (depth 2, segments: ['', 'dashboard', 'settings']): selected = \"profile\"\n *\n * Design docs: design/19-client-navigation.md, design/14-ecosystem.md\n */\n\n'use client';\n\nimport { useSegmentContext } from './segment-context.js';\nimport { usePathname } from './use-pathname.js';\n\n/**\n * Split a pathname into URL segments.\n * \"/\" → [\"\"]\n * \"/dashboard\" → [\"\", \"dashboard\"]\n * \"/dashboard/settings\" → [\"\", \"dashboard\", \"settings\"]\n */\nexport function pathnameToSegments(pathname: string): string[] {\n return pathname.split('/');\n}\n\n/**\n * Pure function: compute the selected child segment given a layout's segment\n * depth and the current URL pathname.\n *\n * @param contextSegments — segments from root to the calling layout, or null if no context\n * @param pathname — current URL pathname\n * @returns the active child segment one level below, or null if at the leaf\n */\nexport function getSelectedSegment(\n contextSegments: string[] | null,\n pathname: string\n): string | null {\n const urlSegments = pathnameToSegments(pathname);\n\n if (!contextSegments) {\n return urlSegments[1] || null;\n }\n\n const depth = contextSegments.length;\n return urlSegments[depth] || null;\n}\n\n/**\n * Pure function: compute all selected segments below a layout's depth.\n *\n * @param contextSegments — segments from root to the calling layout, or null if no context\n * @param pathname — current URL pathname\n * @returns all active segments below the layout\n */\nexport function getSelectedSegments(contextSegments: string[] | null, pathname: string): string[] {\n const urlSegments = pathnameToSegments(pathname);\n\n if (!contextSegments) {\n return urlSegments.slice(1).filter(Boolean);\n }\n\n const depth = contextSegments.length;\n return urlSegments.slice(depth).filter(Boolean);\n}\n\n/**\n * Returns the active child segment one level below the layout where this\n * hook is called. Returns `null` if the layout is the leaf (no child segment).\n *\n * Compatible with Next.js's `useSelectedLayoutSegment()` from `next/navigation`.\n *\n * @param parallelRouteKey — Optional parallel route key. Currently unused\n * (parallel route segment tracking is not yet implemented). Accepted for\n * API compatibility with Next.js.\n */\nexport function useSelectedLayoutSegment(parallelRouteKey?: string): string | null {\n void parallelRouteKey;\n const context = useSegmentContext();\n const pathname = usePathname();\n return getSelectedSegment(context?.segments ?? null, pathname);\n}\n\n/**\n * Returns all active segments below the layout where this hook is called.\n * Returns an empty array if the layout is the leaf (no child segments).\n *\n * Compatible with Next.js's `useSelectedLayoutSegments()` from `next/navigation`.\n *\n * @param parallelRouteKey — Optional parallel route key. Currently unused\n * (parallel route segment tracking is not yet implemented). Accepted for\n * API compatibility with Next.js.\n */\nexport function useSelectedLayoutSegments(parallelRouteKey?: string): string[] {\n void parallelRouteKey;\n const context = useSegmentContext();\n const pathname = usePathname();\n return getSelectedSegments(context?.segments ?? null, pathname);\n}\n","/**\n * Client-side form utilities for server actions.\n *\n * Exports a typed `useActionState` that understands the action builder's result shape.\n * Result is typed to:\n * { data: T } | { validationErrors: Record<string, string[]> } | { serverError: { code, data? } } | null\n *\n * The action builder emits a function that satisfies both the direct call signature\n * and React's `(prevState, formData) => Promise<State>` contract.\n *\n * See design/08-forms-and-actions.md §\"Client-Side Form Mechanics\"\n */\n\nimport { useActionState as reactUseActionState, useTransition } from 'react';\nimport type { ActionFn, ActionResult, InputHint, ValidationErrors } from '../server/action-client';\nimport type { FormFlashData } from '../server/form-flash';\n\n// ─── Types ───────────────────────────────────────────────────────────────\n\n/**\n * The action function type accepted by useActionState.\n * Must satisfy React's (prevState, formData) => Promise<State> contract.\n */\nexport type UseActionStateFn<TData> = (\n prevState: ActionResult<TData> | null,\n formData: FormData\n) => Promise<ActionResult<TData>>;\n\n/**\n * Return type of useActionState.\n * [result, formAction, isPending, errors]\n * The 4th element is auto-derived from result via useFormErrors logic.\n */\nexport type UseActionStateReturn<TData> = [\n result: ActionResult<TData> | null,\n formAction: (formData: FormData) => void,\n isPending: boolean,\n errors: FormErrorsResult,\n];\n\n// ─── useActionState ──────────────────────────────────────────────────────\n\n/**\n * Typed wrapper around React 19's `useActionState` that understands\n * the timber action builder's result shape.\n *\n * @param action - A server action created with createActionClient or a raw 'use server' function.\n * @param initialState - Initial state, typically `null`. Pass `getFormFlash()` for no-JS\n * progressive enhancement — the flash seeds the initial state so the form has a\n * single source of truth for both with-JS and no-JS paths.\n * @param permalink - Optional permalink for progressive enhancement (no-JS fallback URL).\n *\n * @example\n * ```tsx\n * 'use client'\n * import { useActionState } from '@timber-js/app/client'\n * import { createTodo } from './actions'\n *\n * export function NewTodoForm({ flash }) {\n * const [result, action, isPending] = useActionState(createTodo, flash)\n * return (\n * <form action={action}>\n * <input name=\"title\" />\n * {result?.validationErrors?.title && <p>{result.validationErrors.title}</p>}\n * <button disabled={isPending}>Add</button>\n * </form>\n * )\n * }\n * ```\n */\nexport function useActionState<TData>(\n action: UseActionStateFn<TData>,\n initialState: ActionResult<TData> | FormFlashData | null,\n permalink?: string\n): UseActionStateReturn<TData> {\n // FormFlashData is structurally compatible with ActionResult at runtime —\n // the cast satisfies React's generic inference which would otherwise widen TData.\n const [result, formAction, isPending] = reactUseActionState(\n action,\n initialState as ActionResult<TData> | null,\n permalink\n );\n const errors = deriveFormErrors(result);\n return [result, formAction, isPending, errors];\n}\n\n// ─── useFormAction ───────────────────────────────────────────────────────\n\n/**\n * Hook for calling a server action imperatively (not via a form).\n * Returns [execute, isPending] where execute accepts the input directly.\n *\n * @example\n * ```tsx\n * const [deleteTodo, isPending] = useFormAction(deleteTodoAction)\n * <button onClick={() => deleteTodo({ id: todo.id })} disabled={isPending}>\n * Delete\n * </button>\n * ```\n */\nexport function useFormAction<TData = unknown, TInput = unknown>(\n action: ActionFn<TData, TInput> | ((input: TInput) => Promise<ActionResult<TData>>)\n): [\n (\n ...args: undefined extends TInput ? [input?: InputHint<TInput>] : [input: InputHint<TInput>]\n ) => Promise<ActionResult<TData>>,\n boolean,\n] {\n const [isPending, startTransition] = useTransition();\n\n const execute = (input?: InputHint<TInput>): Promise<ActionResult<TData>> => {\n return new Promise((resolve) => {\n startTransition(async () => {\n const result = await (action as (input: InputHint<TInput>) => Promise<ActionResult<TData>>)(\n input as InputHint<TInput>\n );\n resolve(result);\n });\n });\n };\n\n return [execute, isPending];\n}\n\n// ─── Form error extraction ────────────────────────────────────────────────\n\n/** Return type of the errors element in useActionState. */\nexport interface FormErrorsResult {\n /** Per-field validation errors keyed by field name. */\n fieldErrors: Record<string, string[]>;\n /** Form-level errors (from `_root` key). */\n formErrors: string[];\n /** Server error if the action threw an ActionError. */\n serverError: { code: string; data?: Record<string, unknown> } | null;\n /** Whether any errors are present. */\n hasErrors: boolean;\n /** Get the first error message for a field, or null. */\n getFieldError: (field: string) => string | null;\n}\n\n/**\n * Derive FormErrorsResult from an action result.\n * Used internally by useActionState 4th tuple element.\n * @internal — exported for test access only.\n */\nexport function deriveFormErrors<TData>(\n result:\n | ActionResult<TData>\n | {\n validationErrors?: ValidationErrors;\n serverError?: { code: string; data?: Record<string, unknown> };\n }\n | null\n): FormErrorsResult {\n const empty: FormErrorsResult = {\n fieldErrors: {},\n formErrors: [],\n serverError: null,\n hasErrors: false,\n getFieldError: () => null,\n };\n\n if (!result) return empty;\n\n const validationErrors = result.validationErrors as ValidationErrors | undefined;\n const serverError = result.serverError as\n | { code: string; data?: Record<string, unknown> }\n | undefined;\n\n if (!validationErrors && !serverError) return empty;\n\n // Separate _root (form-level) errors from field errors\n const fieldErrors: Record<string, string[]> = {};\n const formErrors: string[] = [];\n\n if (validationErrors) {\n for (const [key, messages] of Object.entries(validationErrors)) {\n if (key === '_root') {\n formErrors.push(...messages);\n } else {\n fieldErrors[key] = messages;\n }\n }\n }\n\n const hasErrors =\n Object.keys(fieldErrors).length > 0 || formErrors.length > 0 || serverError != null;\n\n return {\n fieldErrors,\n formErrors,\n serverError: serverError ?? null,\n hasErrors,\n getFieldError(field: string): string | null {\n const errs = fieldErrors[field];\n return errs && errs.length > 0 ? errs[0] : null;\n },\n };\n}\n","// @timber-js/app/client — Client-side primitives\n// These are the primary imports for client components.\n//\n// Framework-internal bootstrap, SSR bridge, and segment plumbing is in\n// #client-internal (Node package import).\n// Design doc: design/triage/api-naming-spike.md §8.5\n\n// JsonSerializable moved to @timber-js/app/codec as the single canonical path.\n// Re-export removed per TIM-721.\nexport type { RenderErrorDigest } from './types';\n\n// Navigation\nexport { Link, interpolateParams, resolveHref, validateLinkHref, buildLinkProps } from './link';\nexport { mergePreservedSearchParams } from '../shared/merge-search-params.js';\nexport type { LinkProps, LinkPropsWithHref, LinkPropsWithParams, LinkBaseProps } from './link';\nexport type { LinkSegmentParams, OnNavigateHandler, OnNavigateEvent } from './link';\n\n// ─── LinkFunction (originally declared here for module augmentation) ───\n//\n// The codegen emits TWO augmentations of this interface:\n//\n// 1. Per-route call signatures — `href: '/products/[id]'` with direct\n// `segmentParams`/`searchParams` types.\n// 2. Catch-all call signatures — `ExternalHref` + computed `string`\n// variable hrefs.\n//\n// Both live in the codegen output (NOT here). Overload ORDER matters\n// for error UX, and TypeScript's declaration-merging rule \"later overload\n// sets ordered first\" means that whichever augmentation block is\n// declared LATER in the codegen file ends up FIRST in the merged call-\n// signature list — and therefore the EARLIER block ends up LAST, which\n// is the overload whose error message TypeScript reports when no\n// overload matches.\n//\n// We want the PER-ROUTE overloads to be the error-reporting ones when\n// a user passes a known literal `href` with a mistyped segmentParam.\n// So the codegen emits them FIRST and the catch-all SECOND. That way\n// a call like:\n//\n// <Link href=\"/products/[id]\" segmentParams={{ id: maybeUndef }} />\n//\n// reports the actual mismatch (`'string | undefined' is not assignable\n// to 'string | number'` on `id`) instead of the notorious \"Type 'string'\n// is not assignable to type 'never'\" cascade that the old fixed\n// catch-all produced (TIM-832, sibling of TIM-830 / TIM-624).\n//\n// The interface is kept as an empty placeholder here so:\n// - module augmentation has a base interface to merge into\n// - `link.tsx` can `import type { LinkFunction }` and use it as the\n// cast target for the `Link` const (an empty interface accepts any\n// non-nullable value, so the cast is valid even without codegen run)\n// - NO call signatures live here — in a fresh checkout where codegen\n// hasn't run yet, invoking `<Link>` is deliberately a type error,\n// which surfaces \"codegen hasn't run\" rather than a misleading\n// structural type mismatch.\n//\n// See TIM-624 (original typed Link), TIM-830 (flat searchParams), and\n// TIM-832 (this restructure for overload error UX).\nexport interface LinkFunction {\n // intentionally empty — call signatures are provided via codegen\n // augmentation in `.timber/timber-routes.d.ts`.\n}\nexport { usePendingNavigation } from './use-pending-navigation';\nexport { useLinkStatus, LinkStatusContext } from './use-link-status';\nexport type { LinkStatus } from './use-link-status';\nexport { useRouter } from './use-router';\nexport type { AppRouterInstance } from './use-router';\nexport { usePathname } from './use-pathname';\n// useSearchParams removed from public exports — lives in next/navigation shim only.\nexport { useSelectedLayoutSegment, useSelectedLayoutSegments } from './use-selected-layout-segment';\n\n// Forms\nexport { useActionState, useFormAction } from './form';\nexport type { UseActionStateFn, UseActionStateReturn, FormErrorsResult } from './form';\n\n// Params — raw hook for untyped access. For typed params, use defineSegmentParams().useSegmentParams().\nexport { useSegmentParams } from './use-segment-params';\n\n// Query states — useQueryStates lives on defineSearchParams().useQueryStates()\n// and is available via direct import for advanced cases.\n\n// Cookies — useCookie lives on defineCookie().useCookie().\n// Types still exported for advanced use.\nexport type { ClientCookieOptions, CookieSetter } from './use-cookie';\n\n// Register the client useSegmentParams hook with defineSegmentParams.\nimport { useSegmentParams as _useSegmentParams } from './use-segment-params.js';\nimport { _registerUseSegmentParams } from '../segment-params/define.js';\n_registerUseSegmentParams(_useSegmentParams);\n"],"mappings":"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"}
|