@timber-js/app 0.2.0-alpha.118 → 0.2.0-alpha.119
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-Cw-7NfJJ.js → actions-DLijOcMe.js} +6 -6
- package/dist/_chunks/{actions-Cw-7NfJJ.js.map → actions-DLijOcMe.js.map} +1 -1
- package/dist/_chunks/{build-output-helper-DhxiT18k.js → build-output-helper-B226qcTe.js} +1 -1
- package/dist/_chunks/{build-output-helper-DhxiT18k.js.map → build-output-helper-B226qcTe.js.map} +1 -1
- package/dist/_chunks/{codec-registry-V7ARyeLN.js → codec-registry-TNqK8N99.js} +1 -1
- package/dist/_chunks/{codec-registry-V7ARyeLN.js.map → codec-registry-TNqK8N99.js.map} +1 -1
- package/dist/_chunks/{define-8zQ8Vm7x.js → define-BY_dwMyV.js} +3 -3
- package/dist/_chunks/{define-8zQ8Vm7x.js.map → define-BY_dwMyV.js.map} +1 -1
- package/dist/_chunks/{define-zQX6e1tN.js → define-BlFkzZFs.js} +3 -3
- package/dist/_chunks/{define-zQX6e1tN.js.map → define-BlFkzZFs.js.map} +1 -1
- package/dist/_chunks/define-schema-CfEb8LSg.js +31 -0
- package/dist/_chunks/define-schema-CfEb8LSg.js.map +1 -0
- package/dist/_chunks/{handler-store-iqnt2ghN.js → handler-store-bIAstrxQ.js} +1 -1
- package/dist/_chunks/{handler-store-iqnt2ghN.js.map → handler-store-bIAstrxQ.js.map} +1 -1
- package/dist/_chunks/{merge-search-params-CwxJzBLS.js → merge-search-params-Cm1j99HC.js} +1 -1
- package/dist/_chunks/{merge-search-params-CwxJzBLS.js.map → merge-search-params-Cm1j99HC.js.map} +1 -1
- package/dist/_chunks/{navigation-root-DKsJ5Di7.js → navigation-root-CPKTmf-y.js} +2 -2
- package/dist/_chunks/{navigation-root-DKsJ5Di7.js.map → navigation-root-CPKTmf-y.js.map} +1 -1
- package/dist/_chunks/{registry-Dp_hJL4s.js → registry-D6-TmNLM.js} +1 -1
- package/dist/_chunks/{registry-Dp_hJL4s.js.map → registry-D6-TmNLM.js.map} +1 -1
- package/dist/_chunks/{resolve-schema-23Mc8aLi.js → resolve-schema-BxtenpoR.js} +2 -2
- package/dist/_chunks/{resolve-schema-23Mc8aLi.js.map → resolve-schema-BxtenpoR.js.map} +1 -1
- package/dist/_chunks/{router-ref-sektkr-t.js → router-ref-115GHAhP.js} +2 -2
- package/dist/_chunks/{router-ref-sektkr-t.js.map → router-ref-115GHAhP.js.map} +1 -1
- package/dist/_chunks/{schema-bridge-eL5vQDSA.js → schema-bridge-o0SwFtXW.js} +1 -1
- package/dist/_chunks/{schema-bridge-eL5vQDSA.js.map → schema-bridge-o0SwFtXW.js.map} +1 -1
- package/dist/_chunks/{segment-context-Bg0pFbLT.js → segment-context-BnxoPSFQ.js} +1 -1
- package/dist/_chunks/{segment-context-Bg0pFbLT.js.map → segment-context-BnxoPSFQ.js.map} +1 -1
- package/dist/_chunks/{ssr-data-BO5OvEvg.js → ssr-data-BP5-JJTi.js} +1 -1
- package/dist/_chunks/{ssr-data-BO5OvEvg.js.map → ssr-data-BP5-JJTi.js.map} +1 -1
- package/dist/_chunks/{stale-reload-BCPRG0Cv.js → stale-reload-DZ_GjtPz.js} +1 -1
- package/dist/_chunks/{stale-reload-BCPRG0Cv.js.map → stale-reload-DZ_GjtPz.js.map} +1 -1
- package/dist/_chunks/{use-query-states-D9SBlAfp.js → use-query-states-C0tklghY.js} +2 -2
- package/dist/_chunks/{use-query-states-D9SBlAfp.js.map → use-query-states-C0tklghY.js.map} +1 -1
- package/dist/_chunks/{use-segment-params-CskiK-59.js → use-segment-params-6W0l-wqA.js} +2 -2
- package/dist/_chunks/{use-segment-params-CskiK-59.js.map → use-segment-params-6W0l-wqA.js.map} +1 -1
- package/dist/_chunks/{validation-Bm3Je-09.js → validation-AfEFM5ze.js} +1 -1
- package/dist/_chunks/{validation-Bm3Je-09.js.map → validation-AfEFM5ze.js.map} +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/client/error-boundary.js +2 -2
- package/dist/client/index.js +9 -9
- package/dist/client/internal.js +7 -7
- package/dist/codec.d.ts +19 -1
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +46 -2
- package/dist/codec.js.map +1 -0
- package/dist/cookies/index.js +3 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +3 -32
- package/dist/search-params/index.js +3 -3
- package/dist/segment-params/index.js +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +3 -3
- package/package.json +7 -6
- package/src/cli.ts +0 -0
- package/src/codec.ts +55 -3
- package/src/index.ts +5 -2
- package/src/server/index.ts +2 -3
- package/LICENSE +0 -8
- package/dist/params/index.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"define-8zQ8Vm7x.js","names":[],"sources":["../../src/search-params/define.ts"],"sourcesContent":["/**\n * defineSearchParams — factory for SearchParamsDefinition<T>.\n *\n * Creates a typed, composable definition for a route's search parameters.\n * Accepts both SearchParamCodec values and Standard Schema objects (Zod,\n * Valibot, ArkType) with auto-detection. Supports URL key aliasing via\n * withUrlKey(), default-omission serialization, and composition via\n * .extend() / .pick().\n *\n * Design doc: design/23-search-params.md §\"defineSearchParams — The Factory\"\n */\n\nimport { useQueryStates as clientUseQueryStates } from '../client/use-query-states.js';\nimport { fromSchema, isStandardSchema, isCodec } from '../schema-bridge.js';\nimport type { StandardSchemaV1 } from '../schema-bridge.js';\nimport type { Codec } from '../codec.js';\n\n// Server-only reference for .get() — avoids pulling server ALS into client bundles.\n// In client environments, .get() throws before reaching this code path.\n//\n// IMPORTANT: This is set eagerly via _setGetSearchParamsFn() at server startup\n// (called from request-context.ts module initialization). It must NOT use\n// dynamic `await import()` at call time because the async microtask from the\n// dynamic import loses AsyncLocalStorage context in React's RSC Flight renderer,\n// breaking getSearchParams() in parallel slot pages. See TIM-523.\nlet _getSearchParamsFn: (() => URLSearchParams) | undefined;\n\n/**\n * Register the getSearchParams function. Called once at module load time\n * from request-context.ts to avoid dynamic import at call time.\n * @internal\n */\nexport function _setGetSearchParamsFn(fn: () => URLSearchParams): void {\n _getSearchParamsFn = fn;\n}\n\nfunction getSearchParamsFromAls(): URLSearchParams {\n if (!_getSearchParamsFn) {\n throw new Error(\n '[timber] searchParams.get() is only available on the server. ' +\n 'Use searchParams.useQueryStates() on the client.'\n );\n }\n return _getSearchParamsFn();\n}\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/**\n * A codec that converts between URL string values and typed values.\n *\n * nuqs parsers implement this interface natively — no adapter needed.\n * Standard Schema objects (Zod, Valibot, ArkType) are auto-detected\n * by defineSearchParams and wrapped via fromSchema.\n */\nexport interface SearchParamCodec<T> extends Codec<T> {\n /** Optional URL key alias, set by withUrlKey(). */\n urlKey?: string;\n}\n\n/** A codec with a URL key alias attached via withUrlKey(). */\nexport interface SearchParamCodecWithUrlKey<T> extends SearchParamCodec<T> {\n urlKey: string;\n}\n\n/** Infer the output type of a codec. */\nexport type InferCodec<C> = C extends SearchParamCodec<infer T> ? T : never;\n\n/** Map of property names to codecs. */\nexport type CodecMap<T extends Record<string, unknown>> = {\n [K in keyof T]: SearchParamCodec<T[K]>;\n};\n\n/** Options for useQueryStates setter. */\nexport interface SetParamsOptions {\n /** Update URL without server roundtrip (default: false). */\n shallow?: boolean;\n /** Scroll to top after update (default: true). */\n scroll?: boolean;\n /** 'push' (default) or 'replace' for history state. */\n history?: 'push' | 'replace';\n}\n\n/** Setter function returned by useQueryStates. */\nexport type SetParams<T> = (values: Partial<T>, options?: SetParamsOptions) => void;\n\n/** Options for useQueryStates hook. */\nexport interface QueryStatesOptions {\n /** Update URL without server roundtrip (default: false). */\n shallow?: boolean;\n /** Scroll to top after update (default: true). */\n scroll?: boolean;\n /** 'push' (default) or 'replace' for history state. */\n history?: 'push' | 'replace';\n}\n\n/**\n * A fully typed, composable search params definition.\n *\n * Returned by defineSearchParams(). Carries a phantom _type property\n * for build-time type extraction.\n */\nexport interface SearchParamsDefinition<T extends Record<string, unknown>> {\n /** Parse raw URL search params into typed values. */\n parse(raw: URLSearchParams | Record<string, string | string[] | undefined>): T;\n /** Parse a Promise of URLSearchParams (e.g., from the ALS `searchParams()` API). */\n parse(raw: Promise<URLSearchParams | Record<string, string | string[] | undefined>>): Promise<T>;\n\n /**\n * Get typed search params from the current request context (ALS-backed).\n *\n * Server-only, sync. Reads getSearchParams() from ALS and parses through codecs.\n * Throws on client.\n *\n * ```tsx\n * // app/products/page.tsx\n * import { searchParams } from './params'\n * export default function Page() {\n * const { page, category } = searchParams.get()\n * }\n * ```\n */\n get(): T;\n\n /** Client hook — reads current URL params and returns typed values + setter. */\n useQueryStates(options?: QueryStatesOptions): [T, SetParams<T>];\n\n /** Extend with additional codecs or Standard Schema objects. */\n extend<U extends Record<string, SearchParamCodec<unknown> | StandardSchemaV1<unknown>>>(\n codecs: U\n ): SearchParamsDefinition<T & { [K in keyof U]: InferField<U[K]> }>;\n\n /** Pick a subset of keys. Preserves codecs and aliases. */\n pick<K extends keyof T & string>(...keys: K[]): SearchParamsDefinition<Pick<T, K>>;\n\n /** Serialize values to a query string (no leading '?'), omitting defaults. */\n serialize(values: Partial<T>): string;\n\n /** Build a full path with query string, omitting defaults. */\n href(pathname: string, values: Partial<T>): string;\n\n /** Build a URLSearchParams instance, omitting defaults. */\n toSearchParams(values: Partial<T>): URLSearchParams;\n\n /** Read-only codec map for spreading into .extend(). */\n codecs: { [K in keyof T]: SearchParamCodec<T[K]> };\n\n /** Read-only URL key alias map. Maps property names to URL query parameter keys. */\n readonly urlKeys: Readonly<Record<string, string>>;\n\n /**\n * Phantom property for build-time type extraction.\n * Never set at runtime — exists only in the type system.\n */\n readonly _type?: T;\n}\n\n// StandardSchemaV1 is imported from schema-bridge.ts — single source of truth.\n// Re-export for consumers that import it from this module.\nexport type { StandardSchemaV1 } from '../schema-bridge.js';\n\n// ---------------------------------------------------------------------------\n// Type-level helpers\n// ---------------------------------------------------------------------------\n\n/** Infer the output type from either a SearchParamCodec or a StandardSchemaV1. */\nexport type InferField<V> =\n V extends SearchParamCodec<infer T> ? T : V extends StandardSchemaV1<infer T> ? T : never;\n\n/** Acceptable field value for defineSearchParams: a codec or a Standard Schema. */\nexport type SearchParamField<T = unknown> = SearchParamCodec<T> | StandardSchemaV1<T>;\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Convert URLSearchParams or a plain record to a normalized record\n * where repeated keys produce arrays.\n */\nfunction normalizeRaw(\n raw: URLSearchParams | Record<string, string | string[] | undefined>\n): Record<string, string | string[] | undefined> {\n if (raw instanceof URLSearchParams) {\n const result: Record<string, string | string[] | undefined> = {};\n for (const key of new Set(raw.keys())) {\n const values = raw.getAll(key);\n result[key] = values.length === 1 ? values[0] : values;\n }\n return result;\n }\n return raw;\n}\n\n/**\n * Compute the serialized default value for a codec. Used for\n * default-omission: when serialize(value) === serialize(parse(undefined)),\n * the field is omitted from the URL.\n */\nfunction getDefaultSerialized<T>(codec: SearchParamCodec<T>): string | null {\n return codec.serialize(codec.parse(undefined));\n}\n\n// isStandardSchema and isCodec are imported from schema-bridge.ts.\n\n/**\n * Resolve a field value to a SearchParamCodec. Auto-detects Standard Schema\n * objects and wraps them with fromSchema. Reads .urlKey from codecs.\n */\nfunction resolveField(\n fieldName: string,\n value: SearchParamField\n): { codec: SearchParamCodec<unknown>; urlKey?: string } {\n // Check for codec first (codecs may also have '~standard' if they're nuqs parsers)\n if (isCodec(value)) {\n return { codec: value, urlKey: value.urlKey };\n }\n\n // Auto-detect Standard Schema\n if (isStandardSchema(value)) {\n return { codec: fromSchema(value) };\n }\n\n throw new Error(\n `[timber] defineSearchParams: field '${fieldName}' is not a valid codec or Standard Schema. ` +\n `Expected an object with { parse, serialize } methods, or a Standard Schema object ` +\n `(Zod, Valibot, ArkType).`\n );\n}\n\n/**\n * Validate that all codecs handle absent params (parse(undefined) doesn't throw).\n * Catches schemas that throw on missing input. `undefined` and `null` are both\n * valid defaults — `undefined` is correct for optional fields (e.g., `z.string().optional()`).\n */\nfunction validateDefaults(codecMap: Record<string, SearchParamCodec<unknown>>): void {\n for (const [key, codec] of Object.entries(codecMap)) {\n try {\n codec.parse(undefined);\n } catch {\n throw new Error(\n `[timber] defineSearchParams: field '${key}' throws when the param is absent.\\n` +\n ` Search params are optional — the URL might not contain ?${key}=anything.\\n` +\n ` Add .default() or .optional() to your schema, or wrap with withDefault().`\n );\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Create a SearchParamsDefinition from a map of codecs and/or Standard Schema\n * objects. Accepts both SearchParamCodec values and raw Zod/Valibot/ArkType\n * schemas with auto-detection.\n *\n * ```ts\n * import { defineSearchParams, withDefault, withUrlKey } from '@timber-js/app/search-params'\n * import { parseAsString, parseAsStringEnum } from 'nuqs'\n * import { z } from 'zod/v4'\n *\n * export const searchParams = defineSearchParams({\n * page: z.coerce.number().int().min(1).default(1), // Standard Schema — auto-wrapped\n * q: withUrlKey(parseAsString, 'search'), // nuqs codec with URL alias\n * sort: withDefault(parseAsStringEnum(['price', 'name']), 'price'),\n * })\n * ```\n */\n/**\n * Overload: accept a Standard Schema object schema (e.g., z.object({...})).\n *\n * The schema must have a `.shape` property whose values are themselves\n * Standard Schema objects. Each shape property becomes a field codec\n * via fromSchema().\n *\n * ```ts\n * const searchParams = defineSearchParams(\n * z.object({ page: z.coerce.number().default(1), q: z.string().optional() })\n * )\n * ```\n */\nexport function defineSearchParams<T extends Record<string, unknown>>(\n schema: StandardSchemaV1<T> & { shape: Record<string, StandardSchemaV1<unknown>> }\n): SearchParamsDefinition<T>;\n\n/**\n * Overload: accept a map of codecs and/or Standard Schema objects.\n */\nexport function defineSearchParams<C extends Record<string, SearchParamField>>(\n codecs: C\n): SearchParamsDefinition<{ [K in keyof C]: InferField<C[K]> }>;\n\nexport function defineSearchParams(\n codecsOrSchema:\n | Record<string, SearchParamField>\n | (StandardSchemaV1<unknown> & { shape: Record<string, StandardSchemaV1<unknown>> })\n): SearchParamsDefinition<Record<string, unknown>> {\n // Detect Standard Schema object with .shape (e.g., z.object(...))\n if (isStandardSchema(codecsOrSchema) && hasShape(codecsOrSchema)) {\n const fieldCodecs: Record<string, SearchParamField> = {};\n for (const [key, fieldSchema] of Object.entries(codecsOrSchema.shape)) {\n if (isStandardSchema(fieldSchema)) {\n fieldCodecs[key] = fieldSchema;\n } else {\n throw new Error(\n `[timber] defineSearchParams: field '${key}' in schema.shape is not a Standard Schema. ` +\n `All shape properties must be Standard Schema objects (Zod, Valibot, ArkType).`\n );\n }\n }\n return defineSearchParamsFromMap(fieldCodecs);\n }\n\n return defineSearchParamsFromMap(codecsOrSchema as Record<string, SearchParamField>);\n}\n\n/** Check if a schema has a .shape property with object-type values. */\nfunction hasShape(schema: unknown): schema is { shape: Record<string, unknown> } {\n return (\n typeof schema === 'object' &&\n schema !== null &&\n 'shape' in schema &&\n typeof (schema as { shape: unknown }).shape === 'object' &&\n (schema as { shape: unknown }).shape !== null\n );\n}\n\nfunction defineSearchParamsFromMap(\n codecs: Record<string, SearchParamField>\n): SearchParamsDefinition<Record<string, unknown>> {\n const resolvedCodecs: Record<string, SearchParamCodec<unknown>> = {};\n const urlKeys: Record<string, string> = {};\n\n for (const [key, value] of Object.entries(codecs)) {\n const resolved = resolveField(key, value as SearchParamField);\n resolvedCodecs[key] = resolved.codec;\n if (resolved.urlKey) {\n urlKeys[key] = resolved.urlKey;\n }\n }\n\n // Validate that all codecs handle absent params\n validateDefaults(resolvedCodecs);\n\n return buildDefinition(resolvedCodecs as unknown as CodecMap<Record<string, unknown>>, urlKeys);\n}\n\n// ---------------------------------------------------------------------------\n// Internal: build the definition object\n// ---------------------------------------------------------------------------\n\n/**\n * Internal: build a SearchParamsDefinition from a typed codec map and url keys.\n */\nfunction buildDefinition<T extends Record<string, unknown>>(\n codecMap: CodecMap<T>,\n urlKeys: Record<string, string>\n): SearchParamsDefinition<T> {\n // Pre-compute default serialized values for omission check\n const defaultSerialized: Record<string, string | null> = {};\n for (const key of Object.keys(codecMap)) {\n defaultSerialized[key] = getDefaultSerialized(codecMap[key as keyof T]);\n }\n\n function getUrlKey(prop: string): string {\n return urlKeys[prop] ?? prop;\n }\n\n // ---- parse ----\n function parseSync(raw: URLSearchParams | Record<string, string | string[] | undefined>): T {\n const normalized = normalizeRaw(raw);\n const result: Record<string, unknown> = {};\n\n for (const prop of Object.keys(codecMap)) {\n const urlKey = getUrlKey(prop);\n const rawValue = normalized[urlKey];\n result[prop] = (codecMap[prop as keyof T] as SearchParamCodec<unknown>).parse(rawValue);\n }\n\n return result as T;\n }\n\n // Overloaded parse: sync when given raw params, async when given a Promise.\n // This enables the ergonomic pattern: await def.parse(searchParams())\n function parse(raw: URLSearchParams | Record<string, string | string[] | undefined>): T;\n function parse(\n raw: Promise<URLSearchParams | Record<string, string | string[] | undefined>>\n ): Promise<T>;\n function parse(\n raw:\n | URLSearchParams\n | Record<string, string | string[] | undefined>\n | Promise<URLSearchParams | Record<string, string | string[] | undefined>>\n ): T | Promise<T> {\n if (raw instanceof Promise) {\n return raw.then(parseSync);\n }\n return parseSync(raw);\n }\n\n // ---- serialize ----\n function serialize(values: Partial<T>): string {\n const parts: string[] = [];\n\n for (const prop of Object.keys(codecMap)) {\n if (!(prop in values)) continue;\n const codec = codecMap[prop as keyof T] as SearchParamCodec<unknown>;\n const serialized = codec.serialize(values[prop as keyof T] as unknown);\n\n // Omit if serialized value matches the default\n if (serialized === defaultSerialized[prop]) continue;\n if (serialized === null) continue;\n\n parts.push(`${encodeURIComponent(getUrlKey(prop))}=${encodeURIComponent(serialized)}`);\n }\n\n return parts.join('&');\n }\n\n // ---- href ----\n function href(pathname: string, values: Partial<T>): string {\n const qs = serialize(values);\n return qs ? `${pathname}?${qs}` : pathname;\n }\n\n // ---- toSearchParams ----\n function toSearchParams(values: Partial<T>): URLSearchParams {\n const usp = new URLSearchParams();\n\n for (const prop of Object.keys(codecMap)) {\n if (!(prop in values)) continue;\n const codec = codecMap[prop as keyof T] as SearchParamCodec<unknown>;\n const serialized = codec.serialize(values[prop as keyof T] as unknown);\n\n if (serialized === defaultSerialized[prop]) continue;\n if (serialized === null) continue;\n\n usp.set(getUrlKey(prop), serialized);\n }\n\n return usp;\n }\n\n // ---- extend ----\n function extend<U extends Record<string, SearchParamCodec<unknown> | StandardSchemaV1<unknown>>>(\n newCodecs: U\n ): SearchParamsDefinition<T & { [K in keyof U]: InferField<U[K]> }> {\n type Combined = T & { [K in keyof U]: InferField<U[K]> };\n\n // Resolve any Standard Schema objects in the extension\n const resolvedNewCodecs: Record<string, SearchParamCodec<unknown>> = {};\n const newUrlKeys: Record<string, string> = {};\n for (const [key, value] of Object.entries(newCodecs)) {\n const resolved = resolveField(key, value as SearchParamField);\n resolvedNewCodecs[key] = resolved.codec;\n if (resolved.urlKey) {\n newUrlKeys[key] = resolved.urlKey;\n }\n }\n\n const combinedCodecs = {\n ...codecMap,\n ...resolvedNewCodecs,\n } as unknown as CodecMap<Combined>;\n\n // Merge URL keys: base keys + new codec urlKeys from withUrlKey\n const combinedUrlKeys: Record<string, string> = { ...urlKeys, ...newUrlKeys };\n\n return buildDefinition<Combined>(combinedCodecs, combinedUrlKeys);\n }\n\n // ---- pick ----\n function pick<K extends keyof T & string>(...keys: K[]): SearchParamsDefinition<Pick<T, K>> {\n const pickedCodecs: Record<string, SearchParamCodec<unknown>> = {};\n const pickedUrlKeys: Record<string, string> = {};\n\n for (const key of keys) {\n pickedCodecs[key] = codecMap[key] as SearchParamCodec<unknown>;\n if (key in urlKeys) {\n pickedUrlKeys[key] = urlKeys[key];\n }\n }\n\n return buildDefinition<Pick<T, K>>(\n pickedCodecs as unknown as CodecMap<Pick<T, K>>,\n pickedUrlKeys\n );\n }\n\n // ---- useQueryStates ----\n // Delegates to the 'use client' implementation from use-query-states.ts.\n //\n // In the RSC environment: use-query-states.ts is transformed by the RSC\n // plugin into a client reference proxy. Calling it throws — correct,\n // because hooks can't run during server component rendering.\n // In SSR: use-query-states.ts is the real nuqs-backed function. Hooks\n // work during SSR's renderToReadableStream, so this works correctly.\n // On the client: same as SSR — the real function is available.\n function useQueryStates(options?: QueryStatesOptions): [T, SetParams<T>] {\n return clientUseQueryStates(codecMap, options, Object.freeze({ ...urlKeys })) as [\n T,\n SetParams<T>,\n ];\n }\n\n // ---- get ----\n // ALS-backed: reads getSearchParams() from the current request context\n // and parses through codecs. Server-only, sync.\n function get(): T {\n if (typeof window !== 'undefined') {\n throw new Error(\n '[timber] searchParams.get() is server-only. ' +\n 'Use searchParams.useQueryStates() on the client.'\n );\n }\n const raw = getSearchParamsFromAls();\n return parseSync(raw);\n }\n\n const definition: SearchParamsDefinition<T> = {\n parse,\n get,\n useQueryStates,\n extend,\n pick,\n serialize,\n href,\n toSearchParams,\n codecs: codecMap,\n urlKeys: Object.freeze({ ...urlKeys }),\n };\n\n return definition;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"define-BY_dwMyV.js","names":[],"sources":["../../src/search-params/define.ts"],"sourcesContent":["/**\n * defineSearchParams — factory for SearchParamsDefinition<T>.\n *\n * Creates a typed, composable definition for a route's search parameters.\n * Accepts both SearchParamCodec values and Standard Schema objects (Zod,\n * Valibot, ArkType) with auto-detection. Supports URL key aliasing via\n * withUrlKey(), default-omission serialization, and composition via\n * .extend() / .pick().\n *\n * Design doc: design/23-search-params.md §\"defineSearchParams — The Factory\"\n */\n\nimport { useQueryStates as clientUseQueryStates } from '../client/use-query-states.js';\nimport { fromSchema, isStandardSchema, isCodec } from '../schema-bridge.js';\nimport type { StandardSchemaV1 } from '../schema-bridge.js';\nimport type { Codec } from '../codec.js';\n\n// Server-only reference for .get() — avoids pulling server ALS into client bundles.\n// In client environments, .get() throws before reaching this code path.\n//\n// IMPORTANT: This is set eagerly via _setGetSearchParamsFn() at server startup\n// (called from request-context.ts module initialization). It must NOT use\n// dynamic `await import()` at call time because the async microtask from the\n// dynamic import loses AsyncLocalStorage context in React's RSC Flight renderer,\n// breaking getSearchParams() in parallel slot pages. See TIM-523.\nlet _getSearchParamsFn: (() => URLSearchParams) | undefined;\n\n/**\n * Register the getSearchParams function. Called once at module load time\n * from request-context.ts to avoid dynamic import at call time.\n * @internal\n */\nexport function _setGetSearchParamsFn(fn: () => URLSearchParams): void {\n _getSearchParamsFn = fn;\n}\n\nfunction getSearchParamsFromAls(): URLSearchParams {\n if (!_getSearchParamsFn) {\n throw new Error(\n '[timber] searchParams.get() is only available on the server. ' +\n 'Use searchParams.useQueryStates() on the client.'\n );\n }\n return _getSearchParamsFn();\n}\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/**\n * A codec that converts between URL string values and typed values.\n *\n * nuqs parsers implement this interface natively — no adapter needed.\n * Standard Schema objects (Zod, Valibot, ArkType) are auto-detected\n * by defineSearchParams and wrapped via fromSchema.\n */\nexport interface SearchParamCodec<T> extends Codec<T> {\n /** Optional URL key alias, set by withUrlKey(). */\n urlKey?: string;\n}\n\n/** A codec with a URL key alias attached via withUrlKey(). */\nexport interface SearchParamCodecWithUrlKey<T> extends SearchParamCodec<T> {\n urlKey: string;\n}\n\n/** Infer the output type of a codec. */\nexport type InferCodec<C> = C extends SearchParamCodec<infer T> ? T : never;\n\n/** Map of property names to codecs. */\nexport type CodecMap<T extends Record<string, unknown>> = {\n [K in keyof T]: SearchParamCodec<T[K]>;\n};\n\n/** Options for useQueryStates setter. */\nexport interface SetParamsOptions {\n /** Update URL without server roundtrip (default: false). */\n shallow?: boolean;\n /** Scroll to top after update (default: true). */\n scroll?: boolean;\n /** 'push' (default) or 'replace' for history state. */\n history?: 'push' | 'replace';\n}\n\n/** Setter function returned by useQueryStates. */\nexport type SetParams<T> = (values: Partial<T>, options?: SetParamsOptions) => void;\n\n/** Options for useQueryStates hook. */\nexport interface QueryStatesOptions {\n /** Update URL without server roundtrip (default: false). */\n shallow?: boolean;\n /** Scroll to top after update (default: true). */\n scroll?: boolean;\n /** 'push' (default) or 'replace' for history state. */\n history?: 'push' | 'replace';\n}\n\n/**\n * A fully typed, composable search params definition.\n *\n * Returned by defineSearchParams(). Carries a phantom _type property\n * for build-time type extraction.\n */\nexport interface SearchParamsDefinition<T extends Record<string, unknown>> {\n /** Parse raw URL search params into typed values. */\n parse(raw: URLSearchParams | Record<string, string | string[] | undefined>): T;\n /** Parse a Promise of URLSearchParams (e.g., from the ALS `searchParams()` API). */\n parse(raw: Promise<URLSearchParams | Record<string, string | string[] | undefined>>): Promise<T>;\n\n /**\n * Get typed search params from the current request context (ALS-backed).\n *\n * Server-only, sync. Reads getSearchParams() from ALS and parses through codecs.\n * Throws on client.\n *\n * ```tsx\n * // app/products/page.tsx\n * import { searchParams } from './params'\n * export default function Page() {\n * const { page, category } = searchParams.get()\n * }\n * ```\n */\n get(): T;\n\n /** Client hook — reads current URL params and returns typed values + setter. */\n useQueryStates(options?: QueryStatesOptions): [T, SetParams<T>];\n\n /** Extend with additional codecs or Standard Schema objects. */\n extend<U extends Record<string, SearchParamCodec<unknown> | StandardSchemaV1<unknown>>>(\n codecs: U\n ): SearchParamsDefinition<T & { [K in keyof U]: InferField<U[K]> }>;\n\n /** Pick a subset of keys. Preserves codecs and aliases. */\n pick<K extends keyof T & string>(...keys: K[]): SearchParamsDefinition<Pick<T, K>>;\n\n /** Serialize values to a query string (no leading '?'), omitting defaults. */\n serialize(values: Partial<T>): string;\n\n /** Build a full path with query string, omitting defaults. */\n href(pathname: string, values: Partial<T>): string;\n\n /** Build a URLSearchParams instance, omitting defaults. */\n toSearchParams(values: Partial<T>): URLSearchParams;\n\n /** Read-only codec map for spreading into .extend(). */\n codecs: { [K in keyof T]: SearchParamCodec<T[K]> };\n\n /** Read-only URL key alias map. Maps property names to URL query parameter keys. */\n readonly urlKeys: Readonly<Record<string, string>>;\n\n /**\n * Phantom property for build-time type extraction.\n * Never set at runtime — exists only in the type system.\n */\n readonly _type?: T;\n}\n\n// StandardSchemaV1 is imported from schema-bridge.ts — single source of truth.\n// Re-export for consumers that import it from this module.\nexport type { StandardSchemaV1 } from '../schema-bridge.js';\n\n// ---------------------------------------------------------------------------\n// Type-level helpers\n// ---------------------------------------------------------------------------\n\n/** Infer the output type from either a SearchParamCodec or a StandardSchemaV1. */\nexport type InferField<V> =\n V extends SearchParamCodec<infer T> ? T : V extends StandardSchemaV1<infer T> ? T : never;\n\n/** Acceptable field value for defineSearchParams: a codec or a Standard Schema. */\nexport type SearchParamField<T = unknown> = SearchParamCodec<T> | StandardSchemaV1<T>;\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Convert URLSearchParams or a plain record to a normalized record\n * where repeated keys produce arrays.\n */\nfunction normalizeRaw(\n raw: URLSearchParams | Record<string, string | string[] | undefined>\n): Record<string, string | string[] | undefined> {\n if (raw instanceof URLSearchParams) {\n const result: Record<string, string | string[] | undefined> = {};\n for (const key of new Set(raw.keys())) {\n const values = raw.getAll(key);\n result[key] = values.length === 1 ? values[0] : values;\n }\n return result;\n }\n return raw;\n}\n\n/**\n * Compute the serialized default value for a codec. Used for\n * default-omission: when serialize(value) === serialize(parse(undefined)),\n * the field is omitted from the URL.\n */\nfunction getDefaultSerialized<T>(codec: SearchParamCodec<T>): string | null {\n return codec.serialize(codec.parse(undefined));\n}\n\n// isStandardSchema and isCodec are imported from schema-bridge.ts.\n\n/**\n * Resolve a field value to a SearchParamCodec. Auto-detects Standard Schema\n * objects and wraps them with fromSchema. Reads .urlKey from codecs.\n */\nfunction resolveField(\n fieldName: string,\n value: SearchParamField\n): { codec: SearchParamCodec<unknown>; urlKey?: string } {\n // Check for codec first (codecs may also have '~standard' if they're nuqs parsers)\n if (isCodec(value)) {\n return { codec: value, urlKey: value.urlKey };\n }\n\n // Auto-detect Standard Schema\n if (isStandardSchema(value)) {\n return { codec: fromSchema(value) };\n }\n\n throw new Error(\n `[timber] defineSearchParams: field '${fieldName}' is not a valid codec or Standard Schema. ` +\n `Expected an object with { parse, serialize } methods, or a Standard Schema object ` +\n `(Zod, Valibot, ArkType).`\n );\n}\n\n/**\n * Validate that all codecs handle absent params (parse(undefined) doesn't throw).\n * Catches schemas that throw on missing input. `undefined` and `null` are both\n * valid defaults — `undefined` is correct for optional fields (e.g., `z.string().optional()`).\n */\nfunction validateDefaults(codecMap: Record<string, SearchParamCodec<unknown>>): void {\n for (const [key, codec] of Object.entries(codecMap)) {\n try {\n codec.parse(undefined);\n } catch {\n throw new Error(\n `[timber] defineSearchParams: field '${key}' throws when the param is absent.\\n` +\n ` Search params are optional — the URL might not contain ?${key}=anything.\\n` +\n ` Add .default() or .optional() to your schema, or wrap with withDefault().`\n );\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Create a SearchParamsDefinition from a map of codecs and/or Standard Schema\n * objects. Accepts both SearchParamCodec values and raw Zod/Valibot/ArkType\n * schemas with auto-detection.\n *\n * ```ts\n * import { defineSearchParams, withDefault, withUrlKey } from '@timber-js/app/search-params'\n * import { parseAsString, parseAsStringEnum } from 'nuqs'\n * import { z } from 'zod/v4'\n *\n * export const searchParams = defineSearchParams({\n * page: z.coerce.number().int().min(1).default(1), // Standard Schema — auto-wrapped\n * q: withUrlKey(parseAsString, 'search'), // nuqs codec with URL alias\n * sort: withDefault(parseAsStringEnum(['price', 'name']), 'price'),\n * })\n * ```\n */\n/**\n * Overload: accept a Standard Schema object schema (e.g., z.object({...})).\n *\n * The schema must have a `.shape` property whose values are themselves\n * Standard Schema objects. Each shape property becomes a field codec\n * via fromSchema().\n *\n * ```ts\n * const searchParams = defineSearchParams(\n * z.object({ page: z.coerce.number().default(1), q: z.string().optional() })\n * )\n * ```\n */\nexport function defineSearchParams<T extends Record<string, unknown>>(\n schema: StandardSchemaV1<T> & { shape: Record<string, StandardSchemaV1<unknown>> }\n): SearchParamsDefinition<T>;\n\n/**\n * Overload: accept a map of codecs and/or Standard Schema objects.\n */\nexport function defineSearchParams<C extends Record<string, SearchParamField>>(\n codecs: C\n): SearchParamsDefinition<{ [K in keyof C]: InferField<C[K]> }>;\n\nexport function defineSearchParams(\n codecsOrSchema:\n | Record<string, SearchParamField>\n | (StandardSchemaV1<unknown> & { shape: Record<string, StandardSchemaV1<unknown>> })\n): SearchParamsDefinition<Record<string, unknown>> {\n // Detect Standard Schema object with .shape (e.g., z.object(...))\n if (isStandardSchema(codecsOrSchema) && hasShape(codecsOrSchema)) {\n const fieldCodecs: Record<string, SearchParamField> = {};\n for (const [key, fieldSchema] of Object.entries(codecsOrSchema.shape)) {\n if (isStandardSchema(fieldSchema)) {\n fieldCodecs[key] = fieldSchema;\n } else {\n throw new Error(\n `[timber] defineSearchParams: field '${key}' in schema.shape is not a Standard Schema. ` +\n `All shape properties must be Standard Schema objects (Zod, Valibot, ArkType).`\n );\n }\n }\n return defineSearchParamsFromMap(fieldCodecs);\n }\n\n return defineSearchParamsFromMap(codecsOrSchema as Record<string, SearchParamField>);\n}\n\n/** Check if a schema has a .shape property with object-type values. */\nfunction hasShape(schema: unknown): schema is { shape: Record<string, unknown> } {\n return (\n typeof schema === 'object' &&\n schema !== null &&\n 'shape' in schema &&\n typeof (schema as { shape: unknown }).shape === 'object' &&\n (schema as { shape: unknown }).shape !== null\n );\n}\n\nfunction defineSearchParamsFromMap(\n codecs: Record<string, SearchParamField>\n): SearchParamsDefinition<Record<string, unknown>> {\n const resolvedCodecs: Record<string, SearchParamCodec<unknown>> = {};\n const urlKeys: Record<string, string> = {};\n\n for (const [key, value] of Object.entries(codecs)) {\n const resolved = resolveField(key, value as SearchParamField);\n resolvedCodecs[key] = resolved.codec;\n if (resolved.urlKey) {\n urlKeys[key] = resolved.urlKey;\n }\n }\n\n // Validate that all codecs handle absent params\n validateDefaults(resolvedCodecs);\n\n return buildDefinition(resolvedCodecs as unknown as CodecMap<Record<string, unknown>>, urlKeys);\n}\n\n// ---------------------------------------------------------------------------\n// Internal: build the definition object\n// ---------------------------------------------------------------------------\n\n/**\n * Internal: build a SearchParamsDefinition from a typed codec map and url keys.\n */\nfunction buildDefinition<T extends Record<string, unknown>>(\n codecMap: CodecMap<T>,\n urlKeys: Record<string, string>\n): SearchParamsDefinition<T> {\n // Pre-compute default serialized values for omission check\n const defaultSerialized: Record<string, string | null> = {};\n for (const key of Object.keys(codecMap)) {\n defaultSerialized[key] = getDefaultSerialized(codecMap[key as keyof T]);\n }\n\n function getUrlKey(prop: string): string {\n return urlKeys[prop] ?? prop;\n }\n\n // ---- parse ----\n function parseSync(raw: URLSearchParams | Record<string, string | string[] | undefined>): T {\n const normalized = normalizeRaw(raw);\n const result: Record<string, unknown> = {};\n\n for (const prop of Object.keys(codecMap)) {\n const urlKey = getUrlKey(prop);\n const rawValue = normalized[urlKey];\n result[prop] = (codecMap[prop as keyof T] as SearchParamCodec<unknown>).parse(rawValue);\n }\n\n return result as T;\n }\n\n // Overloaded parse: sync when given raw params, async when given a Promise.\n // This enables the ergonomic pattern: await def.parse(searchParams())\n function parse(raw: URLSearchParams | Record<string, string | string[] | undefined>): T;\n function parse(\n raw: Promise<URLSearchParams | Record<string, string | string[] | undefined>>\n ): Promise<T>;\n function parse(\n raw:\n | URLSearchParams\n | Record<string, string | string[] | undefined>\n | Promise<URLSearchParams | Record<string, string | string[] | undefined>>\n ): T | Promise<T> {\n if (raw instanceof Promise) {\n return raw.then(parseSync);\n }\n return parseSync(raw);\n }\n\n // ---- serialize ----\n function serialize(values: Partial<T>): string {\n const parts: string[] = [];\n\n for (const prop of Object.keys(codecMap)) {\n if (!(prop in values)) continue;\n const codec = codecMap[prop as keyof T] as SearchParamCodec<unknown>;\n const serialized = codec.serialize(values[prop as keyof T] as unknown);\n\n // Omit if serialized value matches the default\n if (serialized === defaultSerialized[prop]) continue;\n if (serialized === null) continue;\n\n parts.push(`${encodeURIComponent(getUrlKey(prop))}=${encodeURIComponent(serialized)}`);\n }\n\n return parts.join('&');\n }\n\n // ---- href ----\n function href(pathname: string, values: Partial<T>): string {\n const qs = serialize(values);\n return qs ? `${pathname}?${qs}` : pathname;\n }\n\n // ---- toSearchParams ----\n function toSearchParams(values: Partial<T>): URLSearchParams {\n const usp = new URLSearchParams();\n\n for (const prop of Object.keys(codecMap)) {\n if (!(prop in values)) continue;\n const codec = codecMap[prop as keyof T] as SearchParamCodec<unknown>;\n const serialized = codec.serialize(values[prop as keyof T] as unknown);\n\n if (serialized === defaultSerialized[prop]) continue;\n if (serialized === null) continue;\n\n usp.set(getUrlKey(prop), serialized);\n }\n\n return usp;\n }\n\n // ---- extend ----\n function extend<U extends Record<string, SearchParamCodec<unknown> | StandardSchemaV1<unknown>>>(\n newCodecs: U\n ): SearchParamsDefinition<T & { [K in keyof U]: InferField<U[K]> }> {\n type Combined = T & { [K in keyof U]: InferField<U[K]> };\n\n // Resolve any Standard Schema objects in the extension\n const resolvedNewCodecs: Record<string, SearchParamCodec<unknown>> = {};\n const newUrlKeys: Record<string, string> = {};\n for (const [key, value] of Object.entries(newCodecs)) {\n const resolved = resolveField(key, value as SearchParamField);\n resolvedNewCodecs[key] = resolved.codec;\n if (resolved.urlKey) {\n newUrlKeys[key] = resolved.urlKey;\n }\n }\n\n const combinedCodecs = {\n ...codecMap,\n ...resolvedNewCodecs,\n } as unknown as CodecMap<Combined>;\n\n // Merge URL keys: base keys + new codec urlKeys from withUrlKey\n const combinedUrlKeys: Record<string, string> = { ...urlKeys, ...newUrlKeys };\n\n return buildDefinition<Combined>(combinedCodecs, combinedUrlKeys);\n }\n\n // ---- pick ----\n function pick<K extends keyof T & string>(...keys: K[]): SearchParamsDefinition<Pick<T, K>> {\n const pickedCodecs: Record<string, SearchParamCodec<unknown>> = {};\n const pickedUrlKeys: Record<string, string> = {};\n\n for (const key of keys) {\n pickedCodecs[key] = codecMap[key] as SearchParamCodec<unknown>;\n if (key in urlKeys) {\n pickedUrlKeys[key] = urlKeys[key];\n }\n }\n\n return buildDefinition<Pick<T, K>>(\n pickedCodecs as unknown as CodecMap<Pick<T, K>>,\n pickedUrlKeys\n );\n }\n\n // ---- useQueryStates ----\n // Delegates to the 'use client' implementation from use-query-states.ts.\n //\n // In the RSC environment: use-query-states.ts is transformed by the RSC\n // plugin into a client reference proxy. Calling it throws — correct,\n // because hooks can't run during server component rendering.\n // In SSR: use-query-states.ts is the real nuqs-backed function. Hooks\n // work during SSR's renderToReadableStream, so this works correctly.\n // On the client: same as SSR — the real function is available.\n function useQueryStates(options?: QueryStatesOptions): [T, SetParams<T>] {\n return clientUseQueryStates(codecMap, options, Object.freeze({ ...urlKeys })) as [\n T,\n SetParams<T>,\n ];\n }\n\n // ---- get ----\n // ALS-backed: reads getSearchParams() from the current request context\n // and parses through codecs. Server-only, sync.\n function get(): T {\n if (typeof window !== 'undefined') {\n throw new Error(\n '[timber] searchParams.get() is server-only. ' +\n 'Use searchParams.useQueryStates() on the client.'\n );\n }\n const raw = getSearchParamsFromAls();\n return parseSync(raw);\n }\n\n const definition: SearchParamsDefinition<T> = {\n parse,\n get,\n useQueryStates,\n extend,\n pick,\n serialize,\n href,\n toSearchParams,\n codecs: codecMap,\n urlKeys: Object.freeze({ ...urlKeys }),\n };\n\n return definition;\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as validateSync, i as isStandardSchema, r as isCodec } from "./schema-bridge-
|
|
1
|
+
import { a as validateSync, i as isStandardSchema, r as isCodec } from "./schema-bridge-o0SwFtXW.js";
|
|
2
2
|
//#region src/segment-params/define.ts
|
|
3
3
|
var _getSegmentParamsFn;
|
|
4
4
|
var _useSegmentParamsHook;
|
|
@@ -10,7 +10,7 @@ var _useSegmentParamsHook;
|
|
|
10
10
|
function _registerUseSegmentParams(hook) {
|
|
11
11
|
_useSegmentParamsHook = hook;
|
|
12
12
|
}
|
|
13
|
-
if (typeof window !== "undefined" && !_useSegmentParamsHook) import("./use-segment-params-
|
|
13
|
+
if (typeof window !== "undefined" && !_useSegmentParamsHook) import("./use-segment-params-6W0l-wqA.js").then((n) => n.r).then((mod) => {
|
|
14
14
|
if (!_useSegmentParamsHook) _useSegmentParamsHook = mod.useSegmentParams;
|
|
15
15
|
});
|
|
16
16
|
/**
|
|
@@ -121,4 +121,4 @@ function defineSegmentParams(codecs) {
|
|
|
121
121
|
//#endregion
|
|
122
122
|
export { _setGetSegmentParamsFn as n, defineSegmentParams as r, _registerUseSegmentParams as t };
|
|
123
123
|
|
|
124
|
-
//# sourceMappingURL=define-
|
|
124
|
+
//# sourceMappingURL=define-BlFkzZFs.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"define-zQX6e1tN.js","names":[],"sources":["../../src/segment-params/define.ts"],"sourcesContent":["/**\n * defineSegmentParams — factory for typed route param coercion.\n *\n * Creates a ParamsDefinition that coerces raw string params from the\n * URL into typed values. Used by exporting from params.ts (segment-level)\n * convention file.\n *\n * Reuses the shared Codec<T> protocol with Standard Schema auto-detection,\n * same pattern as defineSearchParams. Runtime constraints are stricter:\n * - serialize must return string (not null — path segments can't be omitted)\n * - parse throwing → 404 (invalid param value)\n *\n * Design doc: design/07a-route-params-triage.md\n */\n\nimport type { Codec } from '../codec.js';\nimport {\n type StandardSchemaV1,\n validateSync,\n isStandardSchema,\n isCodec,\n} from '../schema-bridge.js';\n\n// ---------------------------------------------------------------------------\n// Server-only ALS reference for .get()\n// ---------------------------------------------------------------------------\n\n// Same pattern as search-params: eagerly registered at server startup\n// to avoid dynamic imports that lose ALS context. See TIM-523.\nlet _getSegmentParamsFn: (() => Record<string, string | string[]>) | undefined;\nlet _useSegmentParamsHook: (() => Record<string, string | string[]>) | undefined;\n\n/**\n * Register the client useSegmentParams hook.\n * Called by client entry at module load time to avoid require() at call time.\n * @internal\n */\nexport function _registerUseSegmentParams(hook: () => Record<string, string | string[]>): void {\n _useSegmentParamsHook = hook;\n}\n\n// Self-register on the client when the barrel hasn't been imported yet.\n// Handles the edge case where a client component imports only from params.ts\n// without importing anything from @timber-js/app/client (which runs the\n// registration). The dynamic import resolves before React hydration in Vite.\nif (typeof window !== 'undefined' && !_useSegmentParamsHook) {\n import('../client/use-segment-params.js').then((mod) => {\n if (!_useSegmentParamsHook) {\n _useSegmentParamsHook = mod.useSegmentParams;\n }\n });\n}\n\n/**\n * Register the getSegmentParams function. Called once at module load time\n * from request-context.ts to avoid dynamic import at call time.\n * @internal\n */\nexport function _setGetSegmentParamsFn(fn: () => Record<string, string | string[]>): void {\n _getSegmentParamsFn = fn;\n}\n\nfunction getSegmentParamsFromAls(): Record<string, string | string[]> {\n if (!_getSegmentParamsFn) {\n throw new Error(\n '[timber] segmentParams.get() is only available on the server. ' +\n 'Use segmentParams.useSegmentParams() on the client.'\n );\n }\n return _getSegmentParamsFn();\n}\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Infer the output type from a Codec or StandardSchemaV1. */\nexport type InferParamField<V> =\n V extends Codec<infer T> ? T : V extends StandardSchemaV1<infer T> ? T : never;\n\n/** Acceptable field value for defineParams: a Codec or a Standard Schema. */\nexport type ParamField<T = unknown> = Codec<T> | StandardSchemaV1<T>;\n\nexport type { StandardSchemaV1 };\n\n/**\n * A typed route params definition.\n *\n * Returned by defineParams(). Provides parse (string → typed) and\n * serialize (typed → string) for each declared param.\n */\nexport interface ParamsDefinition<T extends Record<string, unknown>> {\n /** Parse raw string params into typed values. Throws on invalid values. */\n parse(raw: Record<string, string | string[]>): T;\n\n /** Serialize typed values back to strings for URL construction. */\n serialize(values: T): Record<string, string>;\n\n /**\n * Get typed segment params from the current request context (ALS).\n *\n * Server-only, sync.\n *\n * ```ts\n * // app/products/[id]/params.ts\n * export const segmentParams = defineSegmentParams({ id: z.coerce.number() })\n *\n * // app/products/[id]/page.tsx\n * import { segmentParams } from './params'\n * export default function Page() {\n * const { id } = segmentParams.get() // id: number\n * }\n * ```\n */\n get(): T;\n\n /**\n * Client hook for accessing typed segment params reactively.\n *\n * ```tsx\n * 'use client'\n * import { segmentParams } from './params'\n * export function ProductHeader() {\n * const { id } = segmentParams.useSegmentParams()\n * }\n * ```\n */\n useSegmentParams(): T;\n\n /** Read-only codec map. */\n codecs: { [K in keyof T]: Codec<T[K]> };\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Wrap a Standard Schema into a Codec for route params.\n *\n * Unlike fromSchema for search params:\n * - Parse throws on failure (no fallback to default)\n * - Serialize returns string (not null)\n */\nfunction fromParamSchema<T>(fieldName: string, schema: StandardSchemaV1<T>): Codec<T> {\n return {\n parse(value: string | string[] | undefined): T {\n // Route params are always strings (single segment) or string[] (catch-all)\n const input = Array.isArray(value) ? value : value;\n\n const result = validateSync(schema, input);\n if (!result.issues) {\n return result.value;\n }\n\n // For route params, parse failure means the param is invalid → throw\n const messages = result.issues.map((i) => i.message).join(', ');\n throw new Error(`[timber] Param '${fieldName}' coercion failed: ${messages}`);\n },\n\n serialize(value: T): string | null {\n if (value === null || value === undefined) {\n return null;\n }\n // Catch-all segments produce arrays — join with '/' for path reconstruction\n if (Array.isArray(value)) {\n return value.join('/');\n }\n return String(value);\n },\n };\n}\n\n/**\n * Resolve a field value to a Codec. Auto-detects Standard Schema objects.\n */\nfunction resolveField(fieldName: string, value: ParamField): Codec<unknown> {\n if (isCodec(value)) {\n return value;\n }\n\n if (isStandardSchema(value)) {\n return fromParamSchema(fieldName, value);\n }\n\n throw new Error(\n `[timber] defineSegmentParams: field '${fieldName}' is not a valid codec or Standard Schema. ` +\n `Expected an object with { parse, serialize } methods, or a Standard Schema object ` +\n `(Zod, Valibot, ArkType).`\n );\n}\n\n/**\n * Validate that no codec's serialize returns null.\n * Route params are structural — they must produce a valid path segment.\n */\nfunction validateSerialize(codecMap: Record<string, Codec<unknown>>): void {\n for (const [key, codec] of Object.entries(codecMap)) {\n // Test serialize with a sample parsed value to check for null\n // We can't exhaustively test, but we can check that serialize(parse(\"test\"))\n // doesn't return null for a basic input.\n try {\n const testValue = codec.parse('test');\n const serialized = codec.serialize(testValue);\n if (serialized === null) {\n throw new Error(\n `[timber] defineSegmentParams: field '${key}' codec.serialize() returned null.\\n` +\n ` Route params are path segments — they cannot be omitted.\\n` +\n ` Ensure serialize() always returns a string.`\n );\n }\n } catch (e) {\n // parse('test') may throw for strict codecs (e.g., number-only).\n // That's fine — it means the codec validates. We only care about\n // serialize returning null, which we can't test without a valid value.\n if (e instanceof Error && e.message.includes('returned null')) {\n throw e;\n }\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Create a ParamsDefinition from a map of codecs and/or Standard Schema objects.\n *\n * ```ts\n * // app/products/[id]/layout.tsx\n * import { defineSegmentParams } from '@timber-js/app/segment-params'\n * import { z } from 'zod/v4'\n *\n * export const segmentParams = defineSegmentParams({\n * id: z.coerce.number().int().positive(),\n * })\n * ```\n */\nexport function defineSegmentParams<C extends Record<string, ParamField>>(\n codecs: C\n): ParamsDefinition<{ [K in keyof C]: InferParamField<C[K]> }> {\n type T = { [K in keyof C]: InferParamField<C[K]> };\n\n const resolvedCodecs: Record<string, Codec<unknown>> = {};\n\n for (const [key, value] of Object.entries(codecs)) {\n resolvedCodecs[key] = resolveField(key, value as ParamField);\n }\n\n // Validate that serialize doesn't return null\n validateSerialize(resolvedCodecs);\n\n // ---- parse ----\n function parse(raw: Record<string, string | string[]>): T {\n const result: Record<string, unknown> = {};\n\n for (const [key, codec] of Object.entries(resolvedCodecs)) {\n const rawValue = raw[key];\n // Skip params not present in the URL at this route depth.\n // This supports TIM-834 inheritance: a parent params.ts can declare\n // codecs for descendant segments. At shallower depths those params\n // don't exist yet — skip them instead of passing undefined to the codec.\n if (rawValue === undefined) continue;\n result[key] = codec.parse(rawValue);\n }\n\n return result as T;\n }\n\n // ---- serialize ----\n function serialize(values: T): Record<string, string> {\n const result: Record<string, string> = {};\n\n for (const [key, codec] of Object.entries(resolvedCodecs)) {\n const serialized = codec.serialize(values[key as keyof T] as unknown);\n if (serialized === null) {\n throw new Error(\n `[timber] params.serialize: field '${key}' serialized to null. ` +\n `Route params must produce a valid path segment.`\n );\n }\n result[key] = serialized;\n }\n\n return result;\n }\n\n // ---- get ----\n // ALS-backed: reads segment params from the current request context.\n // Server-only, sync.\n //\n // The pipeline already coerces params via coerceSegmentParams() which\n // calls parse() and stores typed values in ALS via setSegmentParams().\n // We return those directly instead of re-parsing, because codecs may\n // not be idempotent (e.g., a codec that only accepts raw strings would\n // throw if given an already-parsed value). See TIM-574.\n function get(): T {\n if (typeof window !== 'undefined') {\n throw new Error(\n '[timber] segmentParams.get() is server-only. ' +\n 'Use segmentParams.useSegmentParams() on the client.'\n );\n }\n const params = getSegmentParamsFromAls();\n // params are already coerced by the pipeline — return as-is.\n return params as unknown as T;\n }\n\n // ---- useSegmentParams ----\n // Client hook that reads typed params from the navigation context.\n // Delegates to the existing useSegmentParams hook from use-segment-params.ts.\n function useSegmentParams(): T {\n if (!_useSegmentParamsHook) {\n throw new Error(\n '[timber] segmentParams.useSegmentParams() requires @timber-js/app/client to be loaded. ' +\n 'This hook can only be used in client components.'\n );\n }\n const raw = _useSegmentParamsHook();\n // Params are already coerced by the server pipeline before being sent\n // to the client — return as-is. Re-parsing would break non-idempotent\n // codecs (e.g., z.coerce.number() on an already-parsed number). See TIM-574.\n return raw as unknown as T;\n }\n\n const definition: ParamsDefinition<T> = {\n parse,\n serialize,\n get,\n useSegmentParams,\n codecs: resolvedCodecs as { [K in keyof T]: Codec<T[K]> },\n };\n\n return definition;\n}\n"],"mappings":";;AA6BA,IAAI;AACJ,IAAI;;;;;;AAOJ,SAAgB,0BAA0B,MAAqD;AAC7F,yBAAwB;;AAO1B,IAAI,OAAO,WAAW,eAAe,CAAC,sBACpC,QAAO,oCAAA,MAAA,MAAA,EAAA,EAAA,CAAmC,MAAM,QAAQ;AACtD,KAAI,CAAC,sBACH,yBAAwB,IAAI;EAE9B;;;;;;AAQJ,SAAgB,uBAAuB,IAAmD;AACxF,uBAAsB;;AAGxB,SAAS,0BAA6D;AACpE,KAAI,CAAC,oBACH,OAAM,IAAI,MACR,oHAED;AAEH,QAAO,qBAAqB;;;;;;;;;AA2E9B,SAAS,gBAAmB,WAAmB,QAAuC;AACpF,QAAO;EACL,MAAM,OAAyC;GAI7C,MAAM,SAAS,aAAa,QAFd,MAAM,QAAQ,MAAM,GAAG,QAAQ,MAEH;AAC1C,OAAI,CAAC,OAAO,OACV,QAAO,OAAO;GAIhB,MAAM,WAAW,OAAO,OAAO,KAAK,MAAM,EAAE,QAAQ,CAAC,KAAK,KAAK;AAC/D,SAAM,IAAI,MAAM,mBAAmB,UAAU,qBAAqB,WAAW;;EAG/E,UAAU,OAAyB;AACjC,OAAI,UAAU,QAAQ,UAAU,KAAA,EAC9B,QAAO;AAGT,OAAI,MAAM,QAAQ,MAAM,CACtB,QAAO,MAAM,KAAK,IAAI;AAExB,UAAO,OAAO,MAAM;;EAEvB;;;;;AAMH,SAAS,aAAa,WAAmB,OAAmC;AAC1E,KAAI,QAAQ,MAAM,CAChB,QAAO;AAGT,KAAI,iBAAiB,MAAM,CACzB,QAAO,gBAAgB,WAAW,MAAM;AAG1C,OAAM,IAAI,MACR,wCAAwC,UAAU,uJAGnD;;;;;;AAOH,SAAS,kBAAkB,UAAgD;AACzE,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,SAAS,CAIjD,KAAI;EACF,MAAM,YAAY,MAAM,MAAM,OAAO;AAErC,MADmB,MAAM,UAAU,UAAU,KAC1B,KACjB,OAAM,IAAI,MACR,wCAAwC,IAAI,+IAG7C;UAEI,GAAG;AAIV,MAAI,aAAa,SAAS,EAAE,QAAQ,SAAS,gBAAgB,CAC3D,OAAM;;;;;;;;;;;;;;;;AAuBd,SAAgB,oBACd,QAC6D;CAG7D,MAAM,iBAAiD,EAAE;AAEzD,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,OAAO,CAC/C,gBAAe,OAAO,aAAa,KAAK,MAAoB;AAI9D,mBAAkB,eAAe;CAGjC,SAAS,MAAM,KAA2C;EACxD,MAAM,SAAkC,EAAE;AAE1C,OAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,eAAe,EAAE;GACzD,MAAM,WAAW,IAAI;AAKrB,OAAI,aAAa,KAAA,EAAW;AAC5B,UAAO,OAAO,MAAM,MAAM,SAAS;;AAGrC,SAAO;;CAIT,SAAS,UAAU,QAAmC;EACpD,MAAM,SAAiC,EAAE;AAEzC,OAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,eAAe,EAAE;GACzD,MAAM,aAAa,MAAM,UAAU,OAAO,KAA2B;AACrE,OAAI,eAAe,KACjB,OAAM,IAAI,MACR,qCAAqC,IAAI,uEAE1C;AAEH,UAAO,OAAO;;AAGhB,SAAO;;CAYT,SAAS,MAAS;AAChB,MAAI,OAAO,WAAW,YACpB,OAAM,IAAI,MACR,mGAED;AAIH,SAFe,yBAAyB;;CAQ1C,SAAS,mBAAsB;AAC7B,MAAI,CAAC,sBACH,OAAM,IAAI,MACR,0IAED;AAMH,SAJY,uBAAuB;;AAerC,QARwC;EACtC;EACA;EACA;EACA;EACA,QAAQ;EACT"}
|
|
1
|
+
{"version":3,"file":"define-BlFkzZFs.js","names":[],"sources":["../../src/segment-params/define.ts"],"sourcesContent":["/**\n * defineSegmentParams — factory for typed route param coercion.\n *\n * Creates a ParamsDefinition that coerces raw string params from the\n * URL into typed values. Used by exporting from params.ts (segment-level)\n * convention file.\n *\n * Reuses the shared Codec<T> protocol with Standard Schema auto-detection,\n * same pattern as defineSearchParams. Runtime constraints are stricter:\n * - serialize must return string (not null — path segments can't be omitted)\n * - parse throwing → 404 (invalid param value)\n *\n * Design doc: design/07a-route-params-triage.md\n */\n\nimport type { Codec } from '../codec.js';\nimport {\n type StandardSchemaV1,\n validateSync,\n isStandardSchema,\n isCodec,\n} from '../schema-bridge.js';\n\n// ---------------------------------------------------------------------------\n// Server-only ALS reference for .get()\n// ---------------------------------------------------------------------------\n\n// Same pattern as search-params: eagerly registered at server startup\n// to avoid dynamic imports that lose ALS context. See TIM-523.\nlet _getSegmentParamsFn: (() => Record<string, string | string[]>) | undefined;\nlet _useSegmentParamsHook: (() => Record<string, string | string[]>) | undefined;\n\n/**\n * Register the client useSegmentParams hook.\n * Called by client entry at module load time to avoid require() at call time.\n * @internal\n */\nexport function _registerUseSegmentParams(hook: () => Record<string, string | string[]>): void {\n _useSegmentParamsHook = hook;\n}\n\n// Self-register on the client when the barrel hasn't been imported yet.\n// Handles the edge case where a client component imports only from params.ts\n// without importing anything from @timber-js/app/client (which runs the\n// registration). The dynamic import resolves before React hydration in Vite.\nif (typeof window !== 'undefined' && !_useSegmentParamsHook) {\n import('../client/use-segment-params.js').then((mod) => {\n if (!_useSegmentParamsHook) {\n _useSegmentParamsHook = mod.useSegmentParams;\n }\n });\n}\n\n/**\n * Register the getSegmentParams function. Called once at module load time\n * from request-context.ts to avoid dynamic import at call time.\n * @internal\n */\nexport function _setGetSegmentParamsFn(fn: () => Record<string, string | string[]>): void {\n _getSegmentParamsFn = fn;\n}\n\nfunction getSegmentParamsFromAls(): Record<string, string | string[]> {\n if (!_getSegmentParamsFn) {\n throw new Error(\n '[timber] segmentParams.get() is only available on the server. ' +\n 'Use segmentParams.useSegmentParams() on the client.'\n );\n }\n return _getSegmentParamsFn();\n}\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Infer the output type from a Codec or StandardSchemaV1. */\nexport type InferParamField<V> =\n V extends Codec<infer T> ? T : V extends StandardSchemaV1<infer T> ? T : never;\n\n/** Acceptable field value for defineParams: a Codec or a Standard Schema. */\nexport type ParamField<T = unknown> = Codec<T> | StandardSchemaV1<T>;\n\nexport type { StandardSchemaV1 };\n\n/**\n * A typed route params definition.\n *\n * Returned by defineParams(). Provides parse (string → typed) and\n * serialize (typed → string) for each declared param.\n */\nexport interface ParamsDefinition<T extends Record<string, unknown>> {\n /** Parse raw string params into typed values. Throws on invalid values. */\n parse(raw: Record<string, string | string[]>): T;\n\n /** Serialize typed values back to strings for URL construction. */\n serialize(values: T): Record<string, string>;\n\n /**\n * Get typed segment params from the current request context (ALS).\n *\n * Server-only, sync.\n *\n * ```ts\n * // app/products/[id]/params.ts\n * export const segmentParams = defineSegmentParams({ id: z.coerce.number() })\n *\n * // app/products/[id]/page.tsx\n * import { segmentParams } from './params'\n * export default function Page() {\n * const { id } = segmentParams.get() // id: number\n * }\n * ```\n */\n get(): T;\n\n /**\n * Client hook for accessing typed segment params reactively.\n *\n * ```tsx\n * 'use client'\n * import { segmentParams } from './params'\n * export function ProductHeader() {\n * const { id } = segmentParams.useSegmentParams()\n * }\n * ```\n */\n useSegmentParams(): T;\n\n /** Read-only codec map. */\n codecs: { [K in keyof T]: Codec<T[K]> };\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Wrap a Standard Schema into a Codec for route params.\n *\n * Unlike fromSchema for search params:\n * - Parse throws on failure (no fallback to default)\n * - Serialize returns string (not null)\n */\nfunction fromParamSchema<T>(fieldName: string, schema: StandardSchemaV1<T>): Codec<T> {\n return {\n parse(value: string | string[] | undefined): T {\n // Route params are always strings (single segment) or string[] (catch-all)\n const input = Array.isArray(value) ? value : value;\n\n const result = validateSync(schema, input);\n if (!result.issues) {\n return result.value;\n }\n\n // For route params, parse failure means the param is invalid → throw\n const messages = result.issues.map((i) => i.message).join(', ');\n throw new Error(`[timber] Param '${fieldName}' coercion failed: ${messages}`);\n },\n\n serialize(value: T): string | null {\n if (value === null || value === undefined) {\n return null;\n }\n // Catch-all segments produce arrays — join with '/' for path reconstruction\n if (Array.isArray(value)) {\n return value.join('/');\n }\n return String(value);\n },\n };\n}\n\n/**\n * Resolve a field value to a Codec. Auto-detects Standard Schema objects.\n */\nfunction resolveField(fieldName: string, value: ParamField): Codec<unknown> {\n if (isCodec(value)) {\n return value;\n }\n\n if (isStandardSchema(value)) {\n return fromParamSchema(fieldName, value);\n }\n\n throw new Error(\n `[timber] defineSegmentParams: field '${fieldName}' is not a valid codec or Standard Schema. ` +\n `Expected an object with { parse, serialize } methods, or a Standard Schema object ` +\n `(Zod, Valibot, ArkType).`\n );\n}\n\n/**\n * Validate that no codec's serialize returns null.\n * Route params are structural — they must produce a valid path segment.\n */\nfunction validateSerialize(codecMap: Record<string, Codec<unknown>>): void {\n for (const [key, codec] of Object.entries(codecMap)) {\n // Test serialize with a sample parsed value to check for null\n // We can't exhaustively test, but we can check that serialize(parse(\"test\"))\n // doesn't return null for a basic input.\n try {\n const testValue = codec.parse('test');\n const serialized = codec.serialize(testValue);\n if (serialized === null) {\n throw new Error(\n `[timber] defineSegmentParams: field '${key}' codec.serialize() returned null.\\n` +\n ` Route params are path segments — they cannot be omitted.\\n` +\n ` Ensure serialize() always returns a string.`\n );\n }\n } catch (e) {\n // parse('test') may throw for strict codecs (e.g., number-only).\n // That's fine — it means the codec validates. We only care about\n // serialize returning null, which we can't test without a valid value.\n if (e instanceof Error && e.message.includes('returned null')) {\n throw e;\n }\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Create a ParamsDefinition from a map of codecs and/or Standard Schema objects.\n *\n * ```ts\n * // app/products/[id]/layout.tsx\n * import { defineSegmentParams } from '@timber-js/app/segment-params'\n * import { z } from 'zod/v4'\n *\n * export const segmentParams = defineSegmentParams({\n * id: z.coerce.number().int().positive(),\n * })\n * ```\n */\nexport function defineSegmentParams<C extends Record<string, ParamField>>(\n codecs: C\n): ParamsDefinition<{ [K in keyof C]: InferParamField<C[K]> }> {\n type T = { [K in keyof C]: InferParamField<C[K]> };\n\n const resolvedCodecs: Record<string, Codec<unknown>> = {};\n\n for (const [key, value] of Object.entries(codecs)) {\n resolvedCodecs[key] = resolveField(key, value as ParamField);\n }\n\n // Validate that serialize doesn't return null\n validateSerialize(resolvedCodecs);\n\n // ---- parse ----\n function parse(raw: Record<string, string | string[]>): T {\n const result: Record<string, unknown> = {};\n\n for (const [key, codec] of Object.entries(resolvedCodecs)) {\n const rawValue = raw[key];\n // Skip params not present in the URL at this route depth.\n // This supports TIM-834 inheritance: a parent params.ts can declare\n // codecs for descendant segments. At shallower depths those params\n // don't exist yet — skip them instead of passing undefined to the codec.\n if (rawValue === undefined) continue;\n result[key] = codec.parse(rawValue);\n }\n\n return result as T;\n }\n\n // ---- serialize ----\n function serialize(values: T): Record<string, string> {\n const result: Record<string, string> = {};\n\n for (const [key, codec] of Object.entries(resolvedCodecs)) {\n const serialized = codec.serialize(values[key as keyof T] as unknown);\n if (serialized === null) {\n throw new Error(\n `[timber] params.serialize: field '${key}' serialized to null. ` +\n `Route params must produce a valid path segment.`\n );\n }\n result[key] = serialized;\n }\n\n return result;\n }\n\n // ---- get ----\n // ALS-backed: reads segment params from the current request context.\n // Server-only, sync.\n //\n // The pipeline already coerces params via coerceSegmentParams() which\n // calls parse() and stores typed values in ALS via setSegmentParams().\n // We return those directly instead of re-parsing, because codecs may\n // not be idempotent (e.g., a codec that only accepts raw strings would\n // throw if given an already-parsed value). See TIM-574.\n function get(): T {\n if (typeof window !== 'undefined') {\n throw new Error(\n '[timber] segmentParams.get() is server-only. ' +\n 'Use segmentParams.useSegmentParams() on the client.'\n );\n }\n const params = getSegmentParamsFromAls();\n // params are already coerced by the pipeline — return as-is.\n return params as unknown as T;\n }\n\n // ---- useSegmentParams ----\n // Client hook that reads typed params from the navigation context.\n // Delegates to the existing useSegmentParams hook from use-segment-params.ts.\n function useSegmentParams(): T {\n if (!_useSegmentParamsHook) {\n throw new Error(\n '[timber] segmentParams.useSegmentParams() requires @timber-js/app/client to be loaded. ' +\n 'This hook can only be used in client components.'\n );\n }\n const raw = _useSegmentParamsHook();\n // Params are already coerced by the server pipeline before being sent\n // to the client — return as-is. Re-parsing would break non-idempotent\n // codecs (e.g., z.coerce.number() on an already-parsed number). See TIM-574.\n return raw as unknown as T;\n }\n\n const definition: ParamsDefinition<T> = {\n parse,\n serialize,\n get,\n useSegmentParams,\n codecs: resolvedCodecs as { [K in keyof T]: Codec<T[K]> },\n };\n\n return definition;\n}\n"],"mappings":";;AA6BA,IAAI;AACJ,IAAI;;;;;;AAOJ,SAAgB,0BAA0B,MAAqD;AAC7F,yBAAwB;;AAO1B,IAAI,OAAO,WAAW,eAAe,CAAC,sBACpC,QAAO,oCAAA,MAAA,MAAA,EAAA,EAAA,CAAmC,MAAM,QAAQ;AACtD,KAAI,CAAC,sBACH,yBAAwB,IAAI;EAE9B;;;;;;AAQJ,SAAgB,uBAAuB,IAAmD;AACxF,uBAAsB;;AAGxB,SAAS,0BAA6D;AACpE,KAAI,CAAC,oBACH,OAAM,IAAI,MACR,oHAED;AAEH,QAAO,qBAAqB;;;;;;;;;AA2E9B,SAAS,gBAAmB,WAAmB,QAAuC;AACpF,QAAO;EACL,MAAM,OAAyC;GAI7C,MAAM,SAAS,aAAa,QAFd,MAAM,QAAQ,MAAM,GAAG,QAAQ,MAEH;AAC1C,OAAI,CAAC,OAAO,OACV,QAAO,OAAO;GAIhB,MAAM,WAAW,OAAO,OAAO,KAAK,MAAM,EAAE,QAAQ,CAAC,KAAK,KAAK;AAC/D,SAAM,IAAI,MAAM,mBAAmB,UAAU,qBAAqB,WAAW;;EAG/E,UAAU,OAAyB;AACjC,OAAI,UAAU,QAAQ,UAAU,KAAA,EAC9B,QAAO;AAGT,OAAI,MAAM,QAAQ,MAAM,CACtB,QAAO,MAAM,KAAK,IAAI;AAExB,UAAO,OAAO,MAAM;;EAEvB;;;;;AAMH,SAAS,aAAa,WAAmB,OAAmC;AAC1E,KAAI,QAAQ,MAAM,CAChB,QAAO;AAGT,KAAI,iBAAiB,MAAM,CACzB,QAAO,gBAAgB,WAAW,MAAM;AAG1C,OAAM,IAAI,MACR,wCAAwC,UAAU,uJAGnD;;;;;;AAOH,SAAS,kBAAkB,UAAgD;AACzE,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,SAAS,CAIjD,KAAI;EACF,MAAM,YAAY,MAAM,MAAM,OAAO;AAErC,MADmB,MAAM,UAAU,UAAU,KAC1B,KACjB,OAAM,IAAI,MACR,wCAAwC,IAAI,+IAG7C;UAEI,GAAG;AAIV,MAAI,aAAa,SAAS,EAAE,QAAQ,SAAS,gBAAgB,CAC3D,OAAM;;;;;;;;;;;;;;;;AAuBd,SAAgB,oBACd,QAC6D;CAG7D,MAAM,iBAAiD,EAAE;AAEzD,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,OAAO,CAC/C,gBAAe,OAAO,aAAa,KAAK,MAAoB;AAI9D,mBAAkB,eAAe;CAGjC,SAAS,MAAM,KAA2C;EACxD,MAAM,SAAkC,EAAE;AAE1C,OAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,eAAe,EAAE;GACzD,MAAM,WAAW,IAAI;AAKrB,OAAI,aAAa,KAAA,EAAW;AAC5B,UAAO,OAAO,MAAM,MAAM,SAAS;;AAGrC,SAAO;;CAIT,SAAS,UAAU,QAAmC;EACpD,MAAM,SAAiC,EAAE;AAEzC,OAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,eAAe,EAAE;GACzD,MAAM,aAAa,MAAM,UAAU,OAAO,KAA2B;AACrE,OAAI,eAAe,KACjB,OAAM,IAAI,MACR,qCAAqC,IAAI,uEAE1C;AAEH,UAAO,OAAO;;AAGhB,SAAO;;CAYT,SAAS,MAAS;AAChB,MAAI,OAAO,WAAW,YACpB,OAAM,IAAI,MACR,mGAED;AAIH,SAFe,yBAAyB;;CAQ1C,SAAS,mBAAsB;AAC7B,MAAI,CAAC,sBACH,OAAM,IAAI,MACR,0IAED;AAMH,SAJY,uBAAuB;;AAerC,QARwC;EACtC;EACA;EACA;EACA;EACA,QAAQ;EACT"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region src/params/define-schema.ts
|
|
2
|
+
/**
|
|
3
|
+
* Define the global segment param schema.
|
|
4
|
+
*
|
|
5
|
+
* Each key uses the bracket syntax from the filesystem (`[id]`, `[...slug]`,
|
|
6
|
+
* `[[...slug]]`) mapped to a codec. This makes the relationship between
|
|
7
|
+
* filesystem segments and codecs visually obvious.
|
|
8
|
+
*
|
|
9
|
+
* A dynamic segment with no key here defaults to `string` at codegen time.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* // app/schema.ts
|
|
13
|
+
* import { defineSchema, codec } from '@timber-js/app';
|
|
14
|
+
* import { z } from 'zod';
|
|
15
|
+
*
|
|
16
|
+
* export default defineSchema({
|
|
17
|
+
* segmentParams: {
|
|
18
|
+
* '[artistSlug]': codec.string,
|
|
19
|
+
* '[year]': codec.integer,
|
|
20
|
+
* '[id]': z.coerce.number().int(),
|
|
21
|
+
* },
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
function defineSchema(config) {
|
|
26
|
+
return config;
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
export { defineSchema as t };
|
|
30
|
+
|
|
31
|
+
//# sourceMappingURL=define-schema-CfEb8LSg.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"define-schema-CfEb8LSg.js","names":[],"sources":["../../src/params/define-schema.ts"],"sourcesContent":["/**\n * defineSchema — global segment param schema definition.\n *\n * Identity function at runtime; the type information is what matters.\n * The Vite plugin reads the schema at build time for codegen, and\n * the pipeline reads it at runtime for coercion.\n *\n * Design doc: design/41-global-params.md\n */\n\nimport type { Codec } from '../codec.js';\nimport type { StandardSchemaV1 } from '../schema-bridge.js';\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/**\n * Infer the output type from a Codec, StandardSchemaV1, or nuqs parser.\n *\n * Used by codegen to reference schema types without hardcoding:\n * `InferCodecType<typeof schema.segmentParams['[year]']>`\n */\nexport type InferCodecType<V> =\n V extends Codec<infer T>\n ? T\n : V extends StandardSchemaV1<infer T>\n ? T\n : V extends { parseServerSide: (value: string) => infer T }\n ? T\n : string;\n\n/** Acceptable codec value for segment params: a Codec or Standard Schema. */\ntype SegmentParamCodec = Codec<unknown> | StandardSchemaV1<unknown>;\n\n/** Configuration object for defineSchema. */\nexport interface SchemaConfig<S extends Record<string, SegmentParamCodec>> {\n segmentParams: S;\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Define the global segment param schema.\n *\n * Each key uses the bracket syntax from the filesystem (`[id]`, `[...slug]`,\n * `[[...slug]]`) mapped to a codec. This makes the relationship between\n * filesystem segments and codecs visually obvious.\n *\n * A dynamic segment with no key here defaults to `string` at codegen time.\n *\n * ```ts\n * // app/schema.ts\n * import { defineSchema, codec } from '@timber-js/app';\n * import { z } from 'zod';\n *\n * export default defineSchema({\n * segmentParams: {\n * '[artistSlug]': codec.string,\n * '[year]': codec.integer,\n * '[id]': z.coerce.number().int(),\n * },\n * });\n * ```\n */\nexport function defineSchema<S extends Record<string, SegmentParamCodec>>(\n config: SchemaConfig<S>\n): SchemaConfig<S> {\n return config;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAmEA,SAAgB,aACd,QACiB;AACjB,QAAO"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handler-store-
|
|
1
|
+
{"version":3,"file":"handler-store-bIAstrxQ.js","names":[],"sources":["../../src/cache/handler-store.ts"],"sourcesContent":["/**\n * Module-level cache handler singleton.\n *\n * Lazily initialized to MemoryCacheHandler on first access. The framework\n * replaces this at boot from timber.config.ts via setCacheHandler().\n *\n * This module avoids importing from ./index to prevent circular dependencies.\n */\n\n// Inline the interface to avoid circular import with index.ts\ninterface CacheHandlerLike {\n get(key: string): Promise<{ value: unknown; stale: boolean } | null>;\n set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void>;\n invalidate(opts: { key?: string; tag?: string }): Promise<void>;\n}\n\nlet handler: CacheHandlerLike | null = null;\n\n/** Replace the active cache handler. Called by the framework at boot. */\nexport function setCacheHandler(h: CacheHandlerLike): void {\n handler = h;\n}\n\n/**\n * Get the active cache handler. Creates a default MemoryCacheHandler on\n * first access if none has been set via setCacheHandler().\n */\nexport function getCacheHandler(): CacheHandlerLike {\n if (!handler) {\n // Inline a minimal LRU cache to avoid circular dep with index.ts.\n // In production, the framework always calls setCacheHandler() at boot.\n handler = createDefaultHandler();\n }\n return handler;\n}\n\nfunction createDefaultHandler(): CacheHandlerLike {\n const store = new Map<string, { value: unknown; expiresAt: number; tags: string[] }>();\n const maxEntries = 1000;\n\n return {\n async get(key) {\n const entry = store.get(key);\n if (!entry) return null;\n store.delete(key);\n store.set(key, entry);\n const stale = Date.now() > entry.expiresAt;\n return { value: entry.value, stale };\n },\n async set(key, value, opts) {\n if (store.has(key)) store.delete(key);\n while (store.size >= maxEntries) {\n const oldest = store.keys().next().value;\n if (oldest !== undefined) store.delete(oldest);\n else break;\n }\n store.set(key, { value, expiresAt: Date.now() + opts.ttl * 1000, tags: opts.tags });\n },\n async invalidate(opts) {\n if (opts.key) store.delete(opts.key);\n if (opts.tag) {\n for (const [key, entry] of store) {\n if (entry.tags.includes(opts.tag)) store.delete(key);\n }\n }\n },\n };\n}\n"],"mappings":";AAgBA,IAAI,UAAmC;;AAGvC,SAAgB,gBAAgB,GAA2B;AACzD,WAAU;;;;;;AAOZ,SAAgB,kBAAoC;AAClD,KAAI,CAAC,QAGH,WAAU,sBAAsB;AAElC,QAAO;;AAGT,SAAS,uBAAyC;CAChD,MAAM,wBAAQ,IAAI,KAAoE;CACtF,MAAM,aAAa;AAEnB,QAAO;EACL,MAAM,IAAI,KAAK;GACb,MAAM,QAAQ,MAAM,IAAI,IAAI;AAC5B,OAAI,CAAC,MAAO,QAAO;AACnB,SAAM,OAAO,IAAI;AACjB,SAAM,IAAI,KAAK,MAAM;GACrB,MAAM,QAAQ,KAAK,KAAK,GAAG,MAAM;AACjC,UAAO;IAAE,OAAO,MAAM;IAAO;IAAO;;EAEtC,MAAM,IAAI,KAAK,OAAO,MAAM;AAC1B,OAAI,MAAM,IAAI,IAAI,CAAE,OAAM,OAAO,IAAI;AACrC,UAAO,MAAM,QAAQ,YAAY;IAC/B,MAAM,SAAS,MAAM,MAAM,CAAC,MAAM,CAAC;AACnC,QAAI,WAAW,KAAA,EAAW,OAAM,OAAO,OAAO;QACzC;;AAEP,SAAM,IAAI,KAAK;IAAE;IAAO,WAAW,KAAK,KAAK,GAAG,KAAK,MAAM;IAAM,MAAM,KAAK;IAAM,CAAC;;EAErF,MAAM,WAAW,MAAM;AACrB,OAAI,KAAK,IAAK,OAAM,OAAO,KAAK,IAAI;AACpC,OAAI,KAAK;SACF,MAAM,CAAC,KAAK,UAAU,MACzB,KAAI,MAAM,KAAK,SAAS,KAAK,IAAI,CAAE,OAAM,OAAO,IAAI;;;EAI3D"}
|
package/dist/_chunks/{merge-search-params-CwxJzBLS.js.map → merge-search-params-Cm1j99HC.js.map}
RENAMED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"merge-search-params-
|
|
1
|
+
{"version":3,"file":"merge-search-params-Cm1j99HC.js","names":[],"sources":["../../src/shared/merge-search-params.ts"],"sourcesContent":["/**\n * Shared utility for merging preserved search params into a target URL.\n *\n * Used by both <Link> (client) and redirect() (server). Extracted to a shared\n * module to avoid importing client code ('use client') from server modules.\n */\n\n/**\n * Merge preserved search params from the current URL into a target href.\n *\n * When `preserve` is `true`, all current search params are merged.\n * When `preserve` is a `string[]`, only the named params are merged.\n *\n * The target href's own search params take precedence — preserved params\n * are only added if the target doesn't already define them.\n *\n * @param targetHref - The resolved target href (may already contain query string)\n * @param currentSearch - The current URL's search string (e.g. \"?private=access&page=2\")\n * @param preserve - `true` to preserve all, or `string[]` to preserve specific params\n * @returns The target href with preserved search params merged in\n */\nexport function mergePreservedSearchParams(\n targetHref: string,\n currentSearch: string,\n preserve: true | string[]\n): string {\n const currentParams = new URLSearchParams(currentSearch);\n if (currentParams.size === 0) return targetHref;\n\n // Split hash fragment from target before processing query params.\n // Hash must come after query string: /path?query=value#hash\n const hashIdx = targetHref.indexOf('#');\n const hrefWithoutHash = hashIdx >= 0 ? targetHref.slice(0, hashIdx) : targetHref;\n const hash = hashIdx >= 0 ? targetHref.slice(hashIdx) : '';\n\n // Split target into path and existing query\n const qIdx = hrefWithoutHash.indexOf('?');\n const targetPath = qIdx >= 0 ? hrefWithoutHash.slice(0, qIdx) : hrefWithoutHash;\n const targetParams = new URLSearchParams(qIdx >= 0 ? hrefWithoutHash.slice(qIdx + 1) : '');\n\n // Collect params to preserve (that aren't already in the target)\n const merged = new URLSearchParams(targetParams);\n for (const [key, value] of currentParams) {\n // Only preserve if: (a) not already in target, and (b) included in preserve list\n if (!targetParams.has(key)) {\n if (preserve === true || preserve.includes(key)) {\n merged.append(key, value);\n }\n }\n }\n\n const qs = merged.toString();\n const pathWithQuery = qs ? `${targetPath}?${qs}` : targetPath;\n return pathWithQuery + hash;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,2BACd,YACA,eACA,UACQ;CACR,MAAM,gBAAgB,IAAI,gBAAgB,cAAc;AACxD,KAAI,cAAc,SAAS,EAAG,QAAO;CAIrC,MAAM,UAAU,WAAW,QAAQ,IAAI;CACvC,MAAM,kBAAkB,WAAW,IAAI,WAAW,MAAM,GAAG,QAAQ,GAAG;CACtE,MAAM,OAAO,WAAW,IAAI,WAAW,MAAM,QAAQ,GAAG;CAGxD,MAAM,OAAO,gBAAgB,QAAQ,IAAI;CACzC,MAAM,aAAa,QAAQ,IAAI,gBAAgB,MAAM,GAAG,KAAK,GAAG;CAChE,MAAM,eAAe,IAAI,gBAAgB,QAAQ,IAAI,gBAAgB,MAAM,OAAO,EAAE,GAAG,GAAG;CAG1F,MAAM,SAAS,IAAI,gBAAgB,aAAa;AAChD,MAAK,MAAM,CAAC,KAAK,UAAU,cAEzB,KAAI,CAAC,aAAa,IAAI,IAAI;MACpB,aAAa,QAAQ,SAAS,SAAS,IAAI,CAC7C,QAAO,OAAO,KAAK,MAAM;;CAK/B,MAAM,KAAK,OAAO,UAAU;AAE5B,SADsB,KAAK,GAAG,WAAW,GAAG,OAAO,cAC5B"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import "./use-segment-params-
|
|
1
|
+
import "./use-segment-params-6W0l-wqA.js";
|
|
2
2
|
import "react";
|
|
3
3
|
//#region src/client/link-pending-store.ts
|
|
4
4
|
var LINK_PENDING_KEY = Symbol.for("__timber_link_pending");
|
|
@@ -93,4 +93,4 @@ function setHardNavigating(value) {
|
|
|
93
93
|
//#endregion
|
|
94
94
|
export { unmountLinkForCurrentNavigation as i, LINK_IDLE as n, setLinkForCurrentNavigation as r, setHardNavigating as t };
|
|
95
95
|
|
|
96
|
-
//# sourceMappingURL=navigation-root-
|
|
96
|
+
//# sourceMappingURL=navigation-root-CPKTmf-y.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"navigation-root-DKsJ5Di7.js","names":[],"sources":["../../src/client/link-pending-store.ts","../../src/client/top-loader.tsx","../../src/client/navigation-root.tsx"],"sourcesContent":["/**\n * Link Pending Store — per-link optimistic pending state.\n *\n * Tracks which link instance is currently navigating so that only the\n * clicked link shows pending. Uses `useOptimistic` from React 19 —\n * the optimistic value (isPending=true) is set inside NavigationRoot's\n * async startTransition so it persists for the duration of the RSC\n * fetch, then auto-reverts to idle when the transition settles.\n *\n * Flow:\n * 1. Link click → setLinkForCurrentNavigation(instance) stores the ref\n * 2. NavigationRoot startTransition → activateLinkPending() calls\n * instance.setIsPending(LINK_PENDING) inside the async transition\n * 3. Transition settles → useOptimistic auto-reverts to LINK_IDLE\n *\n * SINGLETON GUARANTEE: Uses `globalThis` via `Symbol.for` keys because\n * the RSC client bundler can duplicate this module across chunks.\n *\n * See design/19-client-navigation.md §\"Per-Link Pending State\"\n */\n\nimport type { LinkStatus } from './use-link-status.js';\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface LinkPendingInstance {\n setIsPending: (status: LinkStatus) => void;\n}\n\n// ─── Constants ───────────────────────────────────────────────────\n\nconst LINK_PENDING_KEY = Symbol.for('__timber_link_pending');\n\n/** Status object: link navigation in flight */\nexport const LINK_PENDING: LinkStatus = { isPending: true };\n\n/** Status object: link idle */\nexport const LINK_IDLE: LinkStatus = { isPending: false };\n\n// ─── Singleton Storage ───────────────────────────────────────────\n\nfunction getStore(): { current: LinkPendingInstance | null } {\n const g = globalThis as Record<symbol, unknown>;\n if (!g[LINK_PENDING_KEY]) {\n g[LINK_PENDING_KEY] = { current: null };\n }\n return g[LINK_PENDING_KEY] as { current: LinkPendingInstance | null };\n}\n\n// ─── Public API ──────────────────────────────────────────────────\n\n/**\n * Register the link instance that initiated the current navigation.\n * Called from Link's click handler. Does NOT call setIsPending —\n * that happens inside NavigationRoot's startTransition via\n * activateLinkPending().\n *\n * Resets the previous link to idle immediately (the old link's\n * useOptimistic reverts since its transition already settled).\n */\nexport function setLinkForCurrentNavigation(link: LinkPendingInstance | null): void {\n const store = getStore();\n store.current = link;\n}\n\n/**\n * Activate pending state on the current link instance.\n * MUST be called inside NavigationRoot's async startTransition —\n * this is what makes useOptimistic persist for the navigation duration.\n */\nexport function activateLinkPending(): void {\n const store = getStore();\n store.current?.setIsPending(LINK_PENDING);\n}\n\n/**\n * Reset the current link's pending state. With useOptimistic this is\n * handled automatically when the transition settles. Kept for callers\n * that explicitly need to clear on error paths.\n */\nexport function resetLinkPending(): void {\n const store = getStore();\n if (store.current) {\n store.current.setIsPending(LINK_IDLE);\n store.current = null;\n }\n}\n\n/**\n * @deprecated No longer needed with useOptimistic. Kept for callers.\n */\nexport function getCurrentNavId(): number {\n return 0;\n}\n\n/**\n * Clean up the link pending store entirely.\n */\nexport function clearLinkPendingSetter(): void {\n getStore().current = null;\n}\n\n/**\n * Unmount a link instance from navigation tracking.\n */\nexport function unmountLinkForCurrentNavigation(link: LinkPendingInstance): void {\n const store = getStore();\n if (store.current === link) {\n store.current = null;\n }\n}\n","/**\n * TopLoader — Built-in progress bar for client navigations.\n *\n * Shows an animated progress bar at the top of the viewport while an RSC\n * navigation is in flight. Injected automatically by the framework into\n * NavigationRoot — users never render this component directly.\n *\n * Configuration is via timber.config.ts `topLoader` key. Enabled by default.\n * Users who want a fully custom progress indicator disable the built-in one\n * (`topLoader: { enabled: false }`) and use `usePendingNavigation()` directly.\n *\n * Animation approach: pure CSS @keyframes. The bar crawls from 0% to ~90%\n * width over ~30s using ease-out timing. When navigation completes, the bar\n * snaps to 100% and fades out over 200ms. No JS animation loops (RAF, setInterval).\n *\n * Phase transitions are derived synchronously during render (React's\n * getDerivedStateFromProps pattern) — no useEffect needed for state tracking.\n * The finishing → hidden cleanup uses onTransitionEnd from the CSS transition.\n *\n * When delay > 0, CSS animation-delay + a visibility keyframe ensure the bar\n * stays invisible during the delay period. If navigation finishes before the\n * delay, the bar was never visible so the finish transition is also invisible.\n *\n * See design/19-client-navigation.md §\"usePendingNavigation()\"\n * See LOCAL-336 for design decisions.\n */\n\n'use client';\n\nimport { useState, createElement } from 'react';\nimport { usePendingNavigationUrl } from './navigation-context.js';\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface TopLoaderConfig {\n /** Whether the top-loader is enabled. Default: true. */\n enabled?: boolean;\n /** Bar color. Default: '#2299DD'. */\n color?: string;\n /** Bar height in pixels. Default: 3. */\n height?: number;\n /** Show subtle glow/shadow effect. Default: false. */\n shadow?: boolean;\n /** Delay in ms before showing the bar. Default: 0. */\n delay?: number;\n /** CSS z-index. Default: 1600. */\n zIndex?: number;\n}\n\n// ─── Defaults ────────────────────────────────────────────────────\n\nconst DEFAULT_COLOR = '#2299DD';\nconst DEFAULT_HEIGHT = 3;\nconst DEFAULT_SHADOW = false;\nconst DEFAULT_DELAY = 0;\nconst DEFAULT_Z_INDEX = 1600;\n\n// ─── Keyframes ───────────────────────────────────────────────────\n\n// Unique keyframes name to avoid collisions with user styles.\nconst CRAWL_KEYFRAMES = '__timber_top_loader_crawl';\nconst APPEAR_KEYFRAMES = '__timber_top_loader_appear';\nconst FINISH_KEYFRAMES = '__timber_top_loader_finish';\n\n// Track whether the @keyframes rules have been injected into the document.\nlet keyframesInjected = false;\n\n/**\n * Inject the @keyframes rules into the document head once.\n * Called during render (idempotent). Uses a <style> tag so the\n * animations are available for inline-styled elements.\n */\nfunction ensureKeyframes(): void {\n if (keyframesInjected) return;\n if (typeof document === 'undefined') return;\n\n const style = document.createElement('style');\n style.textContent = `\n@keyframes ${CRAWL_KEYFRAMES} {\n 0% { width: 0%; }\n 100% { width: 90%; }\n}\n@keyframes ${APPEAR_KEYFRAMES} {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n@keyframes ${FINISH_KEYFRAMES} {\n 0% { width: 90%; opacity: 1; }\n 50% { width: 100%; opacity: 1; }\n 100% { width: 100%; opacity: 0; }\n}\n`;\n document.head.appendChild(style);\n keyframesInjected = true;\n}\n\n// ─── Component ───────────────────────────────────────────────────\n\n/**\n * Internal top-loader component. Injected by NavigationRoot.\n *\n * Reads pending navigation state from PendingNavigationContext.\n * Phase transitions are derived synchronously during render:\n *\n * hidden → crawling: when isPending becomes true\n * crawling → finishing: when isPending becomes false\n * finishing → hidden: when CSS transition ends (onTransitionEnd)\n * finishing → crawling: when isPending becomes true again\n *\n * No useEffect — all state changes are either derived during render\n * (getDerivedStateFromProps pattern) or triggered by DOM events.\n */\nexport function TopLoader({ config }: { config?: TopLoaderConfig }): React.ReactElement | null {\n const pendingUrl = usePendingNavigationUrl();\n // Navigation is pending when the React-based pending URL is set.\n // pendingUrl is set as an urgent update in navigateTransition() —\n // React commits it before the next paint, so the TopLoader appears\n // immediately when a real RSC navigation starts.\n //\n // We intentionally do NOT check hasNativeNavigationTransition() here.\n // The Navigation API's transition is active for ALL intercepted\n // navigations, including shallow URL updates (nuqs search param\n // changes with shallow: true) and prevented navigations. Those\n // briefly set navigation.transition via event.intercept() but do\n // NOT trigger RSC fetches — showing the TopLoader would be incorrect.\n // pendingUrl is the authoritative signal for \"we're fetching RSC data.\"\n const isPending = pendingUrl !== null;\n\n const color = config?.color ?? DEFAULT_COLOR;\n const height = config?.height ?? DEFAULT_HEIGHT;\n const shadow = config?.shadow ?? DEFAULT_SHADOW;\n const delay = config?.delay ?? DEFAULT_DELAY;\n const zIndex = config?.zIndex ?? DEFAULT_Z_INDEX;\n\n const [phase, setPhase] = useState<'hidden' | 'crawling' | 'finishing'>('hidden');\n\n // ─── Synchronous phase derivation (getDerivedStateFromProps) ──\n // React allows setState during render if the value changes — it\n // immediately re-renders with the updated state before committing.\n\n if (isPending && (phase === 'hidden' || phase === 'finishing')) {\n setPhase('crawling');\n }\n if (!isPending && phase === 'crawling') {\n setPhase('finishing');\n }\n\n // Inject keyframes on first visible render (idempotent)\n if (phase !== 'hidden') {\n ensureKeyframes();\n }\n\n if (phase === 'hidden') return null;\n\n // ─── Styles ──────────────────────────────────────────────────\n\n const containerStyle: React.CSSProperties = {\n position: 'fixed',\n top: 0,\n left: 0,\n width: '100%',\n height: `${height}px`,\n zIndex,\n pointerEvents: 'none',\n };\n\n const barStyle: React.CSSProperties = {\n height: '100%',\n backgroundColor: color,\n ...(phase === 'crawling'\n ? {\n // Crawl from 0% to 90% over 30s. When delay > 0, both the crawl\n // and a visibility animation are delayed — the bar stays at width 0%\n // and opacity 0 during the delay, then appears and starts crawling.\n // With delay 0, the appear animation is instant (0s duration, no delay).\n animation: [\n `${CRAWL_KEYFRAMES} 30s ease-out ${delay}ms forwards`,\n `${APPEAR_KEYFRAMES} 0s ${delay}ms both`,\n ].join(', '),\n }\n : {\n // Finishing: fill to 100% then fade out via a keyframe animation.\n // We use a keyframe instead of a CSS transition because the\n // animation-to-transition handoff is unreliable — the browser\n // may not capture the animated width as the transition's \"from\"\n // value when both the animation removal and transition are\n // applied in the same render frame.\n animation: `${FINISH_KEYFRAMES} 400ms ease forwards`,\n }),\n ...(shadow\n ? {\n boxShadow: `0 0 10px ${color}, 0 0 5px ${color}`,\n }\n : {}),\n };\n\n // Clean up the finishing phase when the finish animation completes.\n const handleAnimationEnd =\n phase === 'finishing'\n ? (e: React.AnimationEvent) => {\n if (e.animationName === FINISH_KEYFRAMES) {\n setPhase('hidden');\n }\n }\n : undefined;\n\n return createElement(\n 'div',\n {\n 'style': containerStyle,\n 'aria-hidden': 'true',\n 'data-timber-top-loader': '',\n },\n createElement('div', { style: barStyle, onAnimationEnd: handleAnimationEnd })\n );\n}\n","/**\n * NavigationRoot — Wrapper component for transition-based rendering.\n *\n * Solves the \"new boundary has no old content\" problem for client-side\n * navigation. When React renders a completely new Suspense boundary via\n * root.render(), it shows the fallback immediately — root.render() is\n * always an urgent update regardless of startTransition.\n *\n * NavigationRoot holds the current element in React state. Navigation\n * updates call startTransition(() => setState(newElement)), which IS\n * a transition update. React keeps the old committed tree visible while\n * any new Suspense boundaries in the transition resolve.\n *\n * Also manages `pendingUrl` as React state with an urgent/transition split:\n * - Navigation START: `setPendingUrl(url)` is an urgent update — React\n * commits it before the next paint, showing the spinner immediately.\n * - Navigation END: `setPendingUrl(null)` is inside `startTransition`\n * alongside `setElement(newTree)` — both commit atomically, so the\n * spinner disappears in the same frame as the new content appears.\n *\n * Hard navigation guard: When a hard navigation is triggered (500 error,\n * version skew), the component throws an unresolved thenable AFTER all\n * hooks to suspend forever — preventing React from rendering children\n * during page teardown. The throw must come after hooks to satisfy\n * React's rules (same hook count every render) while still preventing\n * child renders that could hit hook count mismatches in components\n * whose positions shift during teardown. This pattern is borrowed from\n * Next.js (app-router.tsx pushRef.mpaNavigation — also after hooks).\n *\n * See design/05-streaming.md §\"deferSuspenseFor\"\n * See design/19-client-navigation.md §\"NavigationContext\"\n */\n\nimport { createElement, Fragment, startTransition, useState, type ReactNode } from 'react';\nimport { activateLinkPending, resetLinkPending } from './link-pending-store.js';\nimport { PendingNavigationProvider } from './navigation-context.js';\nimport { TopLoader, type TopLoaderConfig } from './top-loader.js';\n\n// ─── Navigation Transition Counter ──────────────────────────────\n// Monotonically increasing counter that increments each time\n// navigateTransition() is called. Used to detect stale transitions:\n// if a newer transition started while the current one's perform()\n// was in flight, the current transition is stale and should reject.\n//\n// Separate from the link-pending navId (which only increments on\n// link clicks). This counter covers all navigation types: link clicks,\n// programmatic navigate(), refresh(), and handlePopState().\n//\n// Uses globalThis for singleton guarantee across chunks — same pattern\n// as NavigationContext and the link pending store.\n\nconst NAV_TRANSITION_KEY = Symbol.for('__timber_nav_transition_counter');\n\nfunction getTransitionCounter(): { id: number } {\n const g = globalThis as Record<symbol, unknown>;\n if (!g[NAV_TRANSITION_KEY]) {\n g[NAV_TRANSITION_KEY] = { id: 0 };\n }\n return g[NAV_TRANSITION_KEY] as { id: number };\n}\n\n// ─── Hard Navigation Guard ──────────────────────────────────────\n\n/**\n * Module-level flag indicating a hard (MPA) navigation is in progress.\n *\n * When true:\n * - NavigationRoot throws an unresolved thenable to suspend forever,\n * preventing React from rendering children during page teardown\n * (avoids \"Rendered more hooks\" crashes).\n * - The Navigation API handler skips interception, letting the browser\n * perform a full page load (prevents infinite loops where\n * window.location.href → navigate event → router.navigate → 500 →\n * window.location.href → ...).\n *\n * Uses globalThis for singleton guarantee across chunks (same pattern\n * as NavigationContext). See design/19-client-navigation.md §\"Singleton\n * Guarantee via globalThis\".\n */\nconst HARD_NAV_KEY = Symbol.for('__timber_hard_navigating');\n\nfunction getHardNavStore(): { value: boolean } {\n const g = globalThis as Record<symbol, unknown>;\n if (!g[HARD_NAV_KEY]) {\n g[HARD_NAV_KEY] = { value: false };\n }\n return g[HARD_NAV_KEY] as { value: boolean };\n}\n\n/**\n * Set the hard-navigating flag. Call this BEFORE setting\n * window.location.href or window.location.reload() to prevent:\n * 1. React from rendering children during page teardown\n * 2. Navigation API from intercepting the hard navigation\n */\nexport function setHardNavigating(value: boolean): void {\n getHardNavStore().value = value;\n}\n\n/**\n * Check if a hard navigation is in progress.\n * Used by NavigationRoot (throw unresolvedThenable) and by the\n * Navigation API handler (skip interception).\n */\nexport function isHardNavigating(): boolean {\n return getHardNavStore().value;\n}\n\n/**\n * A thenable that never resolves. When thrown during React render,\n * it causes the component to suspend forever — React keeps the\n * old committed tree visible and never attempts to render children.\n *\n * This is the same pattern Next.js uses in app-router.tsx for MPA\n * navigations (pushRef.mpaNavigation → throw unresolvedThenable).\n */\n// for React's Suspense mechanism. Same pattern as Next.js's unresolvedThenable.\n// eslint-disable-next-line unicorn/no-thenable -- Intentionally a never-resolving thenable\nconst unresolvedThenable = { then() {} } as PromiseLike<never>;\n\n// ─── Module-level functions ──────────────────────────────────────\n\n/**\n * Module-level reference to the state setter wrapped in startTransition.\n * Used for non-navigation renders (applyRevalidation, popstate replay).\n */\nlet _transitionRender: ((element: ReactNode) => void) | null = null;\n\n/**\n * Module-level reference to the navigation transition function.\n * Wraps a full navigation (fetch + render) in a single startTransition\n * with the pending URL.\n */\nlet _navigateTransition:\n | ((pendingUrl: string, perform: () => Promise<ReactNode>) => Promise<void>)\n | null = null;\n\n// ─── Component ───────────────────────────────────────────────────\n\n/**\n * Root wrapper component that enables transition-based rendering.\n *\n * Renders PendingNavigationProvider around children for the pending URL\n * context. The DOM tree matches the server-rendered HTML during hydration\n * (the provider renders no extra DOM elements).\n *\n * Usage in browser-entry.ts:\n * const rootEl = createElement(NavigationRoot, { initial: wrapped });\n * reactRoot = hydrateRoot(document, rootEl);\n *\n * Subsequent navigations:\n * navigateTransition(url, async () => { fetch; return wrappedElement; });\n *\n * Non-navigation renders:\n * transitionRender(newWrappedElement);\n */\nexport function NavigationRoot({\n initial,\n topLoaderConfig,\n}: {\n initial: ReactNode;\n topLoaderConfig?: TopLoaderConfig;\n}): ReactNode {\n const [element, setElement] = useState<ReactNode>(initial);\n const [pendingUrl, setPendingUrl] = useState<string | null>(null);\n\n // NOTE: We use standalone `startTransition` (imported from 'react'),\n // NOT `useTransition`. The `useTransition` hook's `startTransition`\n // is tied to a single fiber and tracks one async callback at a time.\n // When two navigations overlap (click slow-page, then click dashboard),\n // calling useTransition's startTransition twice with concurrent async\n // callbacks corrupts React's internal hook tracking — causing\n // \"Rendered more hooks than during the previous render.\"\n //\n // Standalone `startTransition` creates independent transition lanes\n // for each call, so concurrent navigations don't interfere. We don't\n // need useTransition's `isPending` — we track pending state via our\n // own `pendingUrl` useState.\n //\n // This matches the Next.js pattern (TIM-625): \"No useTransition in\n // the router at all — only standalone startTransition.\"\n\n // Non-navigation render (revalidation, popstate cached replay).\n _transitionRender = (newElement: ReactNode) => {\n startTransition(() => {\n setElement(newElement);\n });\n };\n\n // Full navigation transition.\n // setPendingUrl(url) is an URGENT update — React commits it before the next\n // paint, so the pending spinner appears immediately when navigation starts.\n // Inside startTransition: the async fetch + setElement + setPendingUrl(null)\n // are deferred. When the transition commits, the new tree and pendingUrl=null\n // both apply in the same React commit — making the pending→active transition\n // atomic (no frame where pending is false but the old tree is still visible).\n _navigateTransition = (url: string, perform: () => Promise<ReactNode>) => {\n // Urgent: show pending state immediately (for TopLoader / usePendingNavigation)\n setPendingUrl(url);\n\n // Increment the transition counter SYNCHRONOUSLY (before startTransition\n // schedules the async work). Each call gets a unique transId; the counter\n // is the same globalThis singleton, so a newer call always has a higher id.\n const counter = getTransitionCounter();\n const transId = ++counter.id;\n\n return new Promise<void>((resolve, reject) => {\n startTransition(async () => {\n // Activate per-link pending state inside this async transition.\n // useOptimistic persists the isPending=true value for the duration\n // of this transition, then auto-reverts when it settles.\n activateLinkPending();\n try {\n const newElement = await perform();\n // Only commit state if this is still the active navigation.\n // A superseded transition's updates must be dropped entirely.\n if (counter.id === transId) {\n setElement(newElement);\n setPendingUrl(null);\n resolve();\n } else {\n // Stale transition — a newer navigation has superseded this one.\n // Reject so the caller (navigate/refresh/handlePopState) doesn't\n // run post-transition side effects (applyHead, scroll, event\n // dispatch) with stale data. All callers catch AbortError.\n reject(new DOMException('Navigation superseded', 'AbortError'));\n }\n } catch (err) {\n // Only clear pending if this is still the active navigation.\n if (counter.id === transId) {\n setPendingUrl(null);\n resetLinkPending();\n }\n reject(err);\n }\n });\n });\n };\n\n // ─── Hard navigation guard ─────────────────────────────────\n // When a hard navigation is in progress (500 error, version skew),\n // suspend forever to prevent React from rendering children during\n // page teardown. This avoids \"Rendered more hooks\" crashes in\n // CHILD components whose hook counts may shift during teardown.\n //\n // CRITICAL: This throw MUST come AFTER all hooks (the two\n // useState calls above). React requires the same hooks to run on\n // every render. If we threw before hooks, React would see 0 hooks\n // on the re-render vs 2 hooks on the initial render — triggering\n // the exact \"Rendered more hooks\" error we're trying to prevent.\n //\n // By placing it after hooks but before the return, all hooks\n // satisfy React's rules, but the thrown thenable prevents any\n // children from rendering. Same pattern as Next.js app-router.tsx\n // (pushRef.mpaNavigation — also placed after all hooks).\n if (isHardNavigating()) {\n throw unresolvedThenable;\n }\n\n // Inject TopLoader alongside the element tree inside PendingNavigationProvider.\n // The TopLoader reads pendingUrl from context to show/hide the progress bar.\n // It is rendered only when not explicitly disabled via config.\n const showTopLoader = topLoaderConfig?.enabled !== false;\n const children = showTopLoader\n ? createElement(Fragment, null, createElement(TopLoader, { config: topLoaderConfig }), element)\n : element;\n return createElement(PendingNavigationProvider, { value: pendingUrl }, children);\n}\n\n// ─── Public API ──────────────────────────────────────────────────\n\n/**\n * Trigger a transition render for non-navigation updates.\n * React keeps the old committed tree visible while any new Suspense\n * boundaries in the update resolve.\n *\n * Used for: applyRevalidation, popstate replay with cached payload.\n */\nexport function transitionRender(element: ReactNode): void {\n if (_transitionRender) {\n _transitionRender(element);\n }\n}\n\n/**\n * Run a full navigation inside a React transition with optimistic pending URL.\n *\n * The `perform` callback runs inside `startTransition` — it should fetch the\n * RSC payload, update router state, and return the wrapped React element.\n * The pending URL shows immediately (urgent update) and reverts\n * to null when the transition commits (atomic with the new tree).\n *\n * Returns a Promise that resolves when the async work completes (note: the\n * React transition may not have committed yet, but all state updates are done).\n *\n * Used for: navigate(), refresh(), popstate with fetch.\n */\nexport function navigateTransition(\n pendingUrl: string,\n perform: () => Promise<ReactNode>\n): Promise<void> {\n if (_navigateTransition) {\n return _navigateTransition(pendingUrl, perform);\n }\n // Fallback: no NavigationRoot mounted (shouldn't happen in production)\n return perform().then(() => {});\n}\n\n/**\n * Check if the NavigationRoot is mounted and ready for renders.\n * Used by browser-entry.ts to guard against renders before hydration.\n */\nexport function isNavigationRootReady(): boolean {\n return _transitionRender !== null;\n}\n\n/**\n * Install one-shot deferred callbacks for the no-RSC bootstrap path (TIM-600).\n *\n * When there's no RSC payload, we can't create a React root immediately —\n * `createRoot(document).render(...)` would blank the SSR HTML. Instead,\n * this sets up `_transitionRender` and `_navigateTransition` so that the\n * first client navigation triggers root creation via `createAndMount`.\n *\n * After `createAndMount` runs, NavigationRoot renders and overwrites these\n * callbacks with its real `startTransition`-based implementations.\n */\nexport function installDeferredNavigation(createAndMount: (initial: ReactNode) => void): void {\n let mounted = false;\n const mountOnce = (element: ReactNode) => {\n if (mounted) return;\n mounted = true;\n createAndMount(element);\n };\n _transitionRender = (element: ReactNode) => {\n mountOnce(element);\n };\n _navigateTransition = async (_pendingUrl: string, perform: () => Promise<ReactNode>) => {\n const element = await perform();\n mountOnce(element);\n };\n}\n"],"mappings":";;;AA+BA,IAAM,mBAAmB,OAAO,IAAI,wBAAwB;;AAM5D,IAAa,YAAwB,EAAE,WAAW,OAAO;AAIzD,SAAS,WAAoD;CAC3D,MAAM,IAAI;AACV,KAAI,CAAC,EAAE,kBACL,GAAE,oBAAoB,EAAE,SAAS,MAAM;AAEzC,QAAO,EAAE;;;;;;;;;;;AAcX,SAAgB,4BAA4B,MAAwC;CAClF,MAAM,QAAQ,UAAU;AACxB,OAAM,UAAU;;;;;AA2ClB,SAAgB,gCAAgC,MAAiC;CAC/E,MAAM,QAAQ,UAAU;AACxB,KAAI,MAAM,YAAY,KACpB,OAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AE7BpB,IAAM,eAAe,OAAO,IAAI,2BAA2B;AAE3D,SAAS,kBAAsC;CAC7C,MAAM,IAAI;AACV,KAAI,CAAC,EAAE,cACL,GAAE,gBAAgB,EAAE,OAAO,OAAO;AAEpC,QAAO,EAAE;;;;;;;;AASX,SAAgB,kBAAkB,OAAsB;AACtD,kBAAiB,CAAC,QAAQ"}
|
|
1
|
+
{"version":3,"file":"navigation-root-CPKTmf-y.js","names":[],"sources":["../../src/client/link-pending-store.ts","../../src/client/top-loader.tsx","../../src/client/navigation-root.tsx"],"sourcesContent":["/**\n * Link Pending Store — per-link optimistic pending state.\n *\n * Tracks which link instance is currently navigating so that only the\n * clicked link shows pending. Uses `useOptimistic` from React 19 —\n * the optimistic value (isPending=true) is set inside NavigationRoot's\n * async startTransition so it persists for the duration of the RSC\n * fetch, then auto-reverts to idle when the transition settles.\n *\n * Flow:\n * 1. Link click → setLinkForCurrentNavigation(instance) stores the ref\n * 2. NavigationRoot startTransition → activateLinkPending() calls\n * instance.setIsPending(LINK_PENDING) inside the async transition\n * 3. Transition settles → useOptimistic auto-reverts to LINK_IDLE\n *\n * SINGLETON GUARANTEE: Uses `globalThis` via `Symbol.for` keys because\n * the RSC client bundler can duplicate this module across chunks.\n *\n * See design/19-client-navigation.md §\"Per-Link Pending State\"\n */\n\nimport type { LinkStatus } from './use-link-status.js';\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface LinkPendingInstance {\n setIsPending: (status: LinkStatus) => void;\n}\n\n// ─── Constants ───────────────────────────────────────────────────\n\nconst LINK_PENDING_KEY = Symbol.for('__timber_link_pending');\n\n/** Status object: link navigation in flight */\nexport const LINK_PENDING: LinkStatus = { isPending: true };\n\n/** Status object: link idle */\nexport const LINK_IDLE: LinkStatus = { isPending: false };\n\n// ─── Singleton Storage ───────────────────────────────────────────\n\nfunction getStore(): { current: LinkPendingInstance | null } {\n const g = globalThis as Record<symbol, unknown>;\n if (!g[LINK_PENDING_KEY]) {\n g[LINK_PENDING_KEY] = { current: null };\n }\n return g[LINK_PENDING_KEY] as { current: LinkPendingInstance | null };\n}\n\n// ─── Public API ──────────────────────────────────────────────────\n\n/**\n * Register the link instance that initiated the current navigation.\n * Called from Link's click handler. Does NOT call setIsPending —\n * that happens inside NavigationRoot's startTransition via\n * activateLinkPending().\n *\n * Resets the previous link to idle immediately (the old link's\n * useOptimistic reverts since its transition already settled).\n */\nexport function setLinkForCurrentNavigation(link: LinkPendingInstance | null): void {\n const store = getStore();\n store.current = link;\n}\n\n/**\n * Activate pending state on the current link instance.\n * MUST be called inside NavigationRoot's async startTransition —\n * this is what makes useOptimistic persist for the navigation duration.\n */\nexport function activateLinkPending(): void {\n const store = getStore();\n store.current?.setIsPending(LINK_PENDING);\n}\n\n/**\n * Reset the current link's pending state. With useOptimistic this is\n * handled automatically when the transition settles. Kept for callers\n * that explicitly need to clear on error paths.\n */\nexport function resetLinkPending(): void {\n const store = getStore();\n if (store.current) {\n store.current.setIsPending(LINK_IDLE);\n store.current = null;\n }\n}\n\n/**\n * @deprecated No longer needed with useOptimistic. Kept for callers.\n */\nexport function getCurrentNavId(): number {\n return 0;\n}\n\n/**\n * Clean up the link pending store entirely.\n */\nexport function clearLinkPendingSetter(): void {\n getStore().current = null;\n}\n\n/**\n * Unmount a link instance from navigation tracking.\n */\nexport function unmountLinkForCurrentNavigation(link: LinkPendingInstance): void {\n const store = getStore();\n if (store.current === link) {\n store.current = null;\n }\n}\n","/**\n * TopLoader — Built-in progress bar for client navigations.\n *\n * Shows an animated progress bar at the top of the viewport while an RSC\n * navigation is in flight. Injected automatically by the framework into\n * NavigationRoot — users never render this component directly.\n *\n * Configuration is via timber.config.ts `topLoader` key. Enabled by default.\n * Users who want a fully custom progress indicator disable the built-in one\n * (`topLoader: { enabled: false }`) and use `usePendingNavigation()` directly.\n *\n * Animation approach: pure CSS @keyframes. The bar crawls from 0% to ~90%\n * width over ~30s using ease-out timing. When navigation completes, the bar\n * snaps to 100% and fades out over 200ms. No JS animation loops (RAF, setInterval).\n *\n * Phase transitions are derived synchronously during render (React's\n * getDerivedStateFromProps pattern) — no useEffect needed for state tracking.\n * The finishing → hidden cleanup uses onTransitionEnd from the CSS transition.\n *\n * When delay > 0, CSS animation-delay + a visibility keyframe ensure the bar\n * stays invisible during the delay period. If navigation finishes before the\n * delay, the bar was never visible so the finish transition is also invisible.\n *\n * See design/19-client-navigation.md §\"usePendingNavigation()\"\n * See LOCAL-336 for design decisions.\n */\n\n'use client';\n\nimport { useState, createElement } from 'react';\nimport { usePendingNavigationUrl } from './navigation-context.js';\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface TopLoaderConfig {\n /** Whether the top-loader is enabled. Default: true. */\n enabled?: boolean;\n /** Bar color. Default: '#2299DD'. */\n color?: string;\n /** Bar height in pixels. Default: 3. */\n height?: number;\n /** Show subtle glow/shadow effect. Default: false. */\n shadow?: boolean;\n /** Delay in ms before showing the bar. Default: 0. */\n delay?: number;\n /** CSS z-index. Default: 1600. */\n zIndex?: number;\n}\n\n// ─── Defaults ────────────────────────────────────────────────────\n\nconst DEFAULT_COLOR = '#2299DD';\nconst DEFAULT_HEIGHT = 3;\nconst DEFAULT_SHADOW = false;\nconst DEFAULT_DELAY = 0;\nconst DEFAULT_Z_INDEX = 1600;\n\n// ─── Keyframes ───────────────────────────────────────────────────\n\n// Unique keyframes name to avoid collisions with user styles.\nconst CRAWL_KEYFRAMES = '__timber_top_loader_crawl';\nconst APPEAR_KEYFRAMES = '__timber_top_loader_appear';\nconst FINISH_KEYFRAMES = '__timber_top_loader_finish';\n\n// Track whether the @keyframes rules have been injected into the document.\nlet keyframesInjected = false;\n\n/**\n * Inject the @keyframes rules into the document head once.\n * Called during render (idempotent). Uses a <style> tag so the\n * animations are available for inline-styled elements.\n */\nfunction ensureKeyframes(): void {\n if (keyframesInjected) return;\n if (typeof document === 'undefined') return;\n\n const style = document.createElement('style');\n style.textContent = `\n@keyframes ${CRAWL_KEYFRAMES} {\n 0% { width: 0%; }\n 100% { width: 90%; }\n}\n@keyframes ${APPEAR_KEYFRAMES} {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n@keyframes ${FINISH_KEYFRAMES} {\n 0% { width: 90%; opacity: 1; }\n 50% { width: 100%; opacity: 1; }\n 100% { width: 100%; opacity: 0; }\n}\n`;\n document.head.appendChild(style);\n keyframesInjected = true;\n}\n\n// ─── Component ───────────────────────────────────────────────────\n\n/**\n * Internal top-loader component. Injected by NavigationRoot.\n *\n * Reads pending navigation state from PendingNavigationContext.\n * Phase transitions are derived synchronously during render:\n *\n * hidden → crawling: when isPending becomes true\n * crawling → finishing: when isPending becomes false\n * finishing → hidden: when CSS transition ends (onTransitionEnd)\n * finishing → crawling: when isPending becomes true again\n *\n * No useEffect — all state changes are either derived during render\n * (getDerivedStateFromProps pattern) or triggered by DOM events.\n */\nexport function TopLoader({ config }: { config?: TopLoaderConfig }): React.ReactElement | null {\n const pendingUrl = usePendingNavigationUrl();\n // Navigation is pending when the React-based pending URL is set.\n // pendingUrl is set as an urgent update in navigateTransition() —\n // React commits it before the next paint, so the TopLoader appears\n // immediately when a real RSC navigation starts.\n //\n // We intentionally do NOT check hasNativeNavigationTransition() here.\n // The Navigation API's transition is active for ALL intercepted\n // navigations, including shallow URL updates (nuqs search param\n // changes with shallow: true) and prevented navigations. Those\n // briefly set navigation.transition via event.intercept() but do\n // NOT trigger RSC fetches — showing the TopLoader would be incorrect.\n // pendingUrl is the authoritative signal for \"we're fetching RSC data.\"\n const isPending = pendingUrl !== null;\n\n const color = config?.color ?? DEFAULT_COLOR;\n const height = config?.height ?? DEFAULT_HEIGHT;\n const shadow = config?.shadow ?? DEFAULT_SHADOW;\n const delay = config?.delay ?? DEFAULT_DELAY;\n const zIndex = config?.zIndex ?? DEFAULT_Z_INDEX;\n\n const [phase, setPhase] = useState<'hidden' | 'crawling' | 'finishing'>('hidden');\n\n // ─── Synchronous phase derivation (getDerivedStateFromProps) ──\n // React allows setState during render if the value changes — it\n // immediately re-renders with the updated state before committing.\n\n if (isPending && (phase === 'hidden' || phase === 'finishing')) {\n setPhase('crawling');\n }\n if (!isPending && phase === 'crawling') {\n setPhase('finishing');\n }\n\n // Inject keyframes on first visible render (idempotent)\n if (phase !== 'hidden') {\n ensureKeyframes();\n }\n\n if (phase === 'hidden') return null;\n\n // ─── Styles ──────────────────────────────────────────────────\n\n const containerStyle: React.CSSProperties = {\n position: 'fixed',\n top: 0,\n left: 0,\n width: '100%',\n height: `${height}px`,\n zIndex,\n pointerEvents: 'none',\n };\n\n const barStyle: React.CSSProperties = {\n height: '100%',\n backgroundColor: color,\n ...(phase === 'crawling'\n ? {\n // Crawl from 0% to 90% over 30s. When delay > 0, both the crawl\n // and a visibility animation are delayed — the bar stays at width 0%\n // and opacity 0 during the delay, then appears and starts crawling.\n // With delay 0, the appear animation is instant (0s duration, no delay).\n animation: [\n `${CRAWL_KEYFRAMES} 30s ease-out ${delay}ms forwards`,\n `${APPEAR_KEYFRAMES} 0s ${delay}ms both`,\n ].join(', '),\n }\n : {\n // Finishing: fill to 100% then fade out via a keyframe animation.\n // We use a keyframe instead of a CSS transition because the\n // animation-to-transition handoff is unreliable — the browser\n // may not capture the animated width as the transition's \"from\"\n // value when both the animation removal and transition are\n // applied in the same render frame.\n animation: `${FINISH_KEYFRAMES} 400ms ease forwards`,\n }),\n ...(shadow\n ? {\n boxShadow: `0 0 10px ${color}, 0 0 5px ${color}`,\n }\n : {}),\n };\n\n // Clean up the finishing phase when the finish animation completes.\n const handleAnimationEnd =\n phase === 'finishing'\n ? (e: React.AnimationEvent) => {\n if (e.animationName === FINISH_KEYFRAMES) {\n setPhase('hidden');\n }\n }\n : undefined;\n\n return createElement(\n 'div',\n {\n 'style': containerStyle,\n 'aria-hidden': 'true',\n 'data-timber-top-loader': '',\n },\n createElement('div', { style: barStyle, onAnimationEnd: handleAnimationEnd })\n );\n}\n","/**\n * NavigationRoot — Wrapper component for transition-based rendering.\n *\n * Solves the \"new boundary has no old content\" problem for client-side\n * navigation. When React renders a completely new Suspense boundary via\n * root.render(), it shows the fallback immediately — root.render() is\n * always an urgent update regardless of startTransition.\n *\n * NavigationRoot holds the current element in React state. Navigation\n * updates call startTransition(() => setState(newElement)), which IS\n * a transition update. React keeps the old committed tree visible while\n * any new Suspense boundaries in the transition resolve.\n *\n * Also manages `pendingUrl` as React state with an urgent/transition split:\n * - Navigation START: `setPendingUrl(url)` is an urgent update — React\n * commits it before the next paint, showing the spinner immediately.\n * - Navigation END: `setPendingUrl(null)` is inside `startTransition`\n * alongside `setElement(newTree)` — both commit atomically, so the\n * spinner disappears in the same frame as the new content appears.\n *\n * Hard navigation guard: When a hard navigation is triggered (500 error,\n * version skew), the component throws an unresolved thenable AFTER all\n * hooks to suspend forever — preventing React from rendering children\n * during page teardown. The throw must come after hooks to satisfy\n * React's rules (same hook count every render) while still preventing\n * child renders that could hit hook count mismatches in components\n * whose positions shift during teardown. This pattern is borrowed from\n * Next.js (app-router.tsx pushRef.mpaNavigation — also after hooks).\n *\n * See design/05-streaming.md §\"deferSuspenseFor\"\n * See design/19-client-navigation.md §\"NavigationContext\"\n */\n\nimport { createElement, Fragment, startTransition, useState, type ReactNode } from 'react';\nimport { activateLinkPending, resetLinkPending } from './link-pending-store.js';\nimport { PendingNavigationProvider } from './navigation-context.js';\nimport { TopLoader, type TopLoaderConfig } from './top-loader.js';\n\n// ─── Navigation Transition Counter ──────────────────────────────\n// Monotonically increasing counter that increments each time\n// navigateTransition() is called. Used to detect stale transitions:\n// if a newer transition started while the current one's perform()\n// was in flight, the current transition is stale and should reject.\n//\n// Separate from the link-pending navId (which only increments on\n// link clicks). This counter covers all navigation types: link clicks,\n// programmatic navigate(), refresh(), and handlePopState().\n//\n// Uses globalThis for singleton guarantee across chunks — same pattern\n// as NavigationContext and the link pending store.\n\nconst NAV_TRANSITION_KEY = Symbol.for('__timber_nav_transition_counter');\n\nfunction getTransitionCounter(): { id: number } {\n const g = globalThis as Record<symbol, unknown>;\n if (!g[NAV_TRANSITION_KEY]) {\n g[NAV_TRANSITION_KEY] = { id: 0 };\n }\n return g[NAV_TRANSITION_KEY] as { id: number };\n}\n\n// ─── Hard Navigation Guard ──────────────────────────────────────\n\n/**\n * Module-level flag indicating a hard (MPA) navigation is in progress.\n *\n * When true:\n * - NavigationRoot throws an unresolved thenable to suspend forever,\n * preventing React from rendering children during page teardown\n * (avoids \"Rendered more hooks\" crashes).\n * - The Navigation API handler skips interception, letting the browser\n * perform a full page load (prevents infinite loops where\n * window.location.href → navigate event → router.navigate → 500 →\n * window.location.href → ...).\n *\n * Uses globalThis for singleton guarantee across chunks (same pattern\n * as NavigationContext). See design/19-client-navigation.md §\"Singleton\n * Guarantee via globalThis\".\n */\nconst HARD_NAV_KEY = Symbol.for('__timber_hard_navigating');\n\nfunction getHardNavStore(): { value: boolean } {\n const g = globalThis as Record<symbol, unknown>;\n if (!g[HARD_NAV_KEY]) {\n g[HARD_NAV_KEY] = { value: false };\n }\n return g[HARD_NAV_KEY] as { value: boolean };\n}\n\n/**\n * Set the hard-navigating flag. Call this BEFORE setting\n * window.location.href or window.location.reload() to prevent:\n * 1. React from rendering children during page teardown\n * 2. Navigation API from intercepting the hard navigation\n */\nexport function setHardNavigating(value: boolean): void {\n getHardNavStore().value = value;\n}\n\n/**\n * Check if a hard navigation is in progress.\n * Used by NavigationRoot (throw unresolvedThenable) and by the\n * Navigation API handler (skip interception).\n */\nexport function isHardNavigating(): boolean {\n return getHardNavStore().value;\n}\n\n/**\n * A thenable that never resolves. When thrown during React render,\n * it causes the component to suspend forever — React keeps the\n * old committed tree visible and never attempts to render children.\n *\n * This is the same pattern Next.js uses in app-router.tsx for MPA\n * navigations (pushRef.mpaNavigation → throw unresolvedThenable).\n */\n// for React's Suspense mechanism. Same pattern as Next.js's unresolvedThenable.\n// eslint-disable-next-line unicorn/no-thenable -- Intentionally a never-resolving thenable\nconst unresolvedThenable = { then() {} } as PromiseLike<never>;\n\n// ─── Module-level functions ──────────────────────────────────────\n\n/**\n * Module-level reference to the state setter wrapped in startTransition.\n * Used for non-navigation renders (applyRevalidation, popstate replay).\n */\nlet _transitionRender: ((element: ReactNode) => void) | null = null;\n\n/**\n * Module-level reference to the navigation transition function.\n * Wraps a full navigation (fetch + render) in a single startTransition\n * with the pending URL.\n */\nlet _navigateTransition:\n | ((pendingUrl: string, perform: () => Promise<ReactNode>) => Promise<void>)\n | null = null;\n\n// ─── Component ───────────────────────────────────────────────────\n\n/**\n * Root wrapper component that enables transition-based rendering.\n *\n * Renders PendingNavigationProvider around children for the pending URL\n * context. The DOM tree matches the server-rendered HTML during hydration\n * (the provider renders no extra DOM elements).\n *\n * Usage in browser-entry.ts:\n * const rootEl = createElement(NavigationRoot, { initial: wrapped });\n * reactRoot = hydrateRoot(document, rootEl);\n *\n * Subsequent navigations:\n * navigateTransition(url, async () => { fetch; return wrappedElement; });\n *\n * Non-navigation renders:\n * transitionRender(newWrappedElement);\n */\nexport function NavigationRoot({\n initial,\n topLoaderConfig,\n}: {\n initial: ReactNode;\n topLoaderConfig?: TopLoaderConfig;\n}): ReactNode {\n const [element, setElement] = useState<ReactNode>(initial);\n const [pendingUrl, setPendingUrl] = useState<string | null>(null);\n\n // NOTE: We use standalone `startTransition` (imported from 'react'),\n // NOT `useTransition`. The `useTransition` hook's `startTransition`\n // is tied to a single fiber and tracks one async callback at a time.\n // When two navigations overlap (click slow-page, then click dashboard),\n // calling useTransition's startTransition twice with concurrent async\n // callbacks corrupts React's internal hook tracking — causing\n // \"Rendered more hooks than during the previous render.\"\n //\n // Standalone `startTransition` creates independent transition lanes\n // for each call, so concurrent navigations don't interfere. We don't\n // need useTransition's `isPending` — we track pending state via our\n // own `pendingUrl` useState.\n //\n // This matches the Next.js pattern (TIM-625): \"No useTransition in\n // the router at all — only standalone startTransition.\"\n\n // Non-navigation render (revalidation, popstate cached replay).\n _transitionRender = (newElement: ReactNode) => {\n startTransition(() => {\n setElement(newElement);\n });\n };\n\n // Full navigation transition.\n // setPendingUrl(url) is an URGENT update — React commits it before the next\n // paint, so the pending spinner appears immediately when navigation starts.\n // Inside startTransition: the async fetch + setElement + setPendingUrl(null)\n // are deferred. When the transition commits, the new tree and pendingUrl=null\n // both apply in the same React commit — making the pending→active transition\n // atomic (no frame where pending is false but the old tree is still visible).\n _navigateTransition = (url: string, perform: () => Promise<ReactNode>) => {\n // Urgent: show pending state immediately (for TopLoader / usePendingNavigation)\n setPendingUrl(url);\n\n // Increment the transition counter SYNCHRONOUSLY (before startTransition\n // schedules the async work). Each call gets a unique transId; the counter\n // is the same globalThis singleton, so a newer call always has a higher id.\n const counter = getTransitionCounter();\n const transId = ++counter.id;\n\n return new Promise<void>((resolve, reject) => {\n startTransition(async () => {\n // Activate per-link pending state inside this async transition.\n // useOptimistic persists the isPending=true value for the duration\n // of this transition, then auto-reverts when it settles.\n activateLinkPending();\n try {\n const newElement = await perform();\n // Only commit state if this is still the active navigation.\n // A superseded transition's updates must be dropped entirely.\n if (counter.id === transId) {\n setElement(newElement);\n setPendingUrl(null);\n resolve();\n } else {\n // Stale transition — a newer navigation has superseded this one.\n // Reject so the caller (navigate/refresh/handlePopState) doesn't\n // run post-transition side effects (applyHead, scroll, event\n // dispatch) with stale data. All callers catch AbortError.\n reject(new DOMException('Navigation superseded', 'AbortError'));\n }\n } catch (err) {\n // Only clear pending if this is still the active navigation.\n if (counter.id === transId) {\n setPendingUrl(null);\n resetLinkPending();\n }\n reject(err);\n }\n });\n });\n };\n\n // ─── Hard navigation guard ─────────────────────────────────\n // When a hard navigation is in progress (500 error, version skew),\n // suspend forever to prevent React from rendering children during\n // page teardown. This avoids \"Rendered more hooks\" crashes in\n // CHILD components whose hook counts may shift during teardown.\n //\n // CRITICAL: This throw MUST come AFTER all hooks (the two\n // useState calls above). React requires the same hooks to run on\n // every render. If we threw before hooks, React would see 0 hooks\n // on the re-render vs 2 hooks on the initial render — triggering\n // the exact \"Rendered more hooks\" error we're trying to prevent.\n //\n // By placing it after hooks but before the return, all hooks\n // satisfy React's rules, but the thrown thenable prevents any\n // children from rendering. Same pattern as Next.js app-router.tsx\n // (pushRef.mpaNavigation — also placed after all hooks).\n if (isHardNavigating()) {\n throw unresolvedThenable;\n }\n\n // Inject TopLoader alongside the element tree inside PendingNavigationProvider.\n // The TopLoader reads pendingUrl from context to show/hide the progress bar.\n // It is rendered only when not explicitly disabled via config.\n const showTopLoader = topLoaderConfig?.enabled !== false;\n const children = showTopLoader\n ? createElement(Fragment, null, createElement(TopLoader, { config: topLoaderConfig }), element)\n : element;\n return createElement(PendingNavigationProvider, { value: pendingUrl }, children);\n}\n\n// ─── Public API ──────────────────────────────────────────────────\n\n/**\n * Trigger a transition render for non-navigation updates.\n * React keeps the old committed tree visible while any new Suspense\n * boundaries in the update resolve.\n *\n * Used for: applyRevalidation, popstate replay with cached payload.\n */\nexport function transitionRender(element: ReactNode): void {\n if (_transitionRender) {\n _transitionRender(element);\n }\n}\n\n/**\n * Run a full navigation inside a React transition with optimistic pending URL.\n *\n * The `perform` callback runs inside `startTransition` — it should fetch the\n * RSC payload, update router state, and return the wrapped React element.\n * The pending URL shows immediately (urgent update) and reverts\n * to null when the transition commits (atomic with the new tree).\n *\n * Returns a Promise that resolves when the async work completes (note: the\n * React transition may not have committed yet, but all state updates are done).\n *\n * Used for: navigate(), refresh(), popstate with fetch.\n */\nexport function navigateTransition(\n pendingUrl: string,\n perform: () => Promise<ReactNode>\n): Promise<void> {\n if (_navigateTransition) {\n return _navigateTransition(pendingUrl, perform);\n }\n // Fallback: no NavigationRoot mounted (shouldn't happen in production)\n return perform().then(() => {});\n}\n\n/**\n * Check if the NavigationRoot is mounted and ready for renders.\n * Used by browser-entry.ts to guard against renders before hydration.\n */\nexport function isNavigationRootReady(): boolean {\n return _transitionRender !== null;\n}\n\n/**\n * Install one-shot deferred callbacks for the no-RSC bootstrap path (TIM-600).\n *\n * When there's no RSC payload, we can't create a React root immediately —\n * `createRoot(document).render(...)` would blank the SSR HTML. Instead,\n * this sets up `_transitionRender` and `_navigateTransition` so that the\n * first client navigation triggers root creation via `createAndMount`.\n *\n * After `createAndMount` runs, NavigationRoot renders and overwrites these\n * callbacks with its real `startTransition`-based implementations.\n */\nexport function installDeferredNavigation(createAndMount: (initial: ReactNode) => void): void {\n let mounted = false;\n const mountOnce = (element: ReactNode) => {\n if (mounted) return;\n mounted = true;\n createAndMount(element);\n };\n _transitionRender = (element: ReactNode) => {\n mountOnce(element);\n };\n _navigateTransition = async (_pendingUrl: string, perform: () => Promise<ReactNode>) => {\n const element = await perform();\n mountOnce(element);\n };\n}\n"],"mappings":";;;AA+BA,IAAM,mBAAmB,OAAO,IAAI,wBAAwB;;AAM5D,IAAa,YAAwB,EAAE,WAAW,OAAO;AAIzD,SAAS,WAAoD;CAC3D,MAAM,IAAI;AACV,KAAI,CAAC,EAAE,kBACL,GAAE,oBAAoB,EAAE,SAAS,MAAM;AAEzC,QAAO,EAAE;;;;;;;;;;;AAcX,SAAgB,4BAA4B,MAAwC;CAClF,MAAM,QAAQ,UAAU;AACxB,OAAM,UAAU;;;;;AA2ClB,SAAgB,gCAAgC,MAAiC;CAC/E,MAAM,QAAQ,UAAU;AACxB,KAAI,MAAM,YAAY,KACpB,OAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AE7BpB,IAAM,eAAe,OAAO,IAAI,2BAA2B;AAE3D,SAAS,kBAAsC;CAC7C,MAAM,IAAI;AACV,KAAI,CAAC,EAAE,cACL,GAAE,gBAAgB,EAAE,OAAO,OAAO;AAEpC,QAAO,EAAE;;;;;;;;AASX,SAAgB,kBAAkB,OAAsB;AACtD,kBAAiB,CAAC,QAAQ"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry-
|
|
1
|
+
{"version":3,"file":"registry-D6-TmNLM.js","names":[],"sources":["../../src/search-params/registry.ts"],"sourcesContent":["/**\n * Runtime registry for route-scoped search params definitions.\n *\n * When a route's modules load, the framework registers its search-params\n * definition here. useQueryStates('/route') resolves codecs from this map.\n *\n * Design doc: design/23-search-params.md §\"Runtime: Registration at Route Load\"\n */\n\nimport type { SearchParamsDefinition } from './define.js';\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst registry = new Map<string, SearchParamsDefinition<any>>();\n\n/**\n * Register a route's search params definition.\n * Called by the generated route manifest loader when a route's modules load.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function registerSearchParams(route: string, definition: SearchParamsDefinition<any>): void {\n registry.set(route, definition);\n}\n\n/**\n * Look up a route's search params definition.\n * Returns undefined if the route hasn't been loaded yet.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function getSearchParamsDefinition(route: string): SearchParamsDefinition<any> | undefined {\n return registry.get(route);\n}\n"],"mappings":";AAYA,IAAM,2BAAW,IAAI,KAA0C;;;;;AAO/D,SAAgB,qBAAqB,OAAe,YAA+C;AACjG,UAAS,IAAI,OAAO,WAAW;;;;;;AAQjC,SAAgB,0BAA0B,OAAwD;AAChG,QAAO,SAAS,IAAI,MAAM"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as validateSync, i as isStandardSchema, r as isCodec } from "./schema-bridge-
|
|
1
|
+
import { a as validateSync, i as isStandardSchema, r as isCodec } from "./schema-bridge-o0SwFtXW.js";
|
|
2
2
|
//#region src/params/resolve-schema.ts
|
|
3
3
|
/**
|
|
4
4
|
* Wrap a Standard Schema into a Codec for route params.
|
|
@@ -62,4 +62,4 @@ function resolveSchemaCodecs(segmentParams) {
|
|
|
62
62
|
//#endregion
|
|
63
63
|
export { toBracketKey as n, resolveSchemaCodecs as t };
|
|
64
64
|
|
|
65
|
-
//# sourceMappingURL=resolve-schema-
|
|
65
|
+
//# sourceMappingURL=resolve-schema-BxtenpoR.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolve-schema-
|
|
1
|
+
{"version":3,"file":"resolve-schema-BxtenpoR.js","names":[],"sources":["../../src/params/resolve-schema.ts"],"sourcesContent":["/**\n * Schema resolution — resolves the global schema's segmentParams into\n * a runtime-ready codec map keyed by bracket name (e.g. '[id]').\n *\n * Bracket keys are preserved so that `[id]`, `[...id]`, and `[[...id]]`\n * remain distinct entries. The coercion layer reconstructs bracket keys\n * from matched segment metadata to look up the correct codec.\n *\n * Design doc: design/41-global-params.md §Pipeline Integration\n */\n\nimport type { Codec } from '../codec.js';\nimport { isCodec, isStandardSchema, validateSync } from '../schema-bridge.js';\nimport type { StandardSchemaV1 } from '../schema-bridge.js';\n\n/**\n * Wrap a Standard Schema into a Codec for route params.\n * Parse throws on failure (invalid param → 404). Serialize returns string.\n */\nfunction fromParamSchema<T>(fieldName: string, schema: StandardSchemaV1<T>): Codec<T> {\n return {\n parse(value: string | string[] | undefined): T {\n const result = validateSync(schema, value);\n if (!result.issues) {\n return result.value;\n }\n const messages = result.issues.map((i) => i.message).join(', ');\n throw new Error(`[timber] Param '${fieldName}' coercion failed: ${messages}`);\n },\n serialize(value: T): string | null {\n if (value === null || value === undefined) return null;\n if (Array.isArray(value)) return value.join('/');\n return String(value);\n },\n };\n}\n\n/**\n * Resolve a schema entry to a Codec. Accepts Codec<T> or StandardSchemaV1<T>.\n */\nfunction resolveCodec(fieldName: string, value: unknown): Codec<unknown> {\n if (isCodec(value)) return value;\n if (isStandardSchema(value)) return fromParamSchema(fieldName, value);\n throw new Error(`[timber] Schema field '${fieldName}' is not a valid codec or Standard Schema.`);\n}\n\n/**\n * Reconstruct the bracket key for a segment from its type and param name.\n * Used by the coercion layer to look up codecs in the bracket-keyed map.\n *\n * 'dynamic' + 'id' → '[id]'\n * 'catch-all' + 'slug' → '[...slug]'\n * 'optional-catch-all' + 'slug' → '[[...slug]]'\n */\nexport function toBracketKey(segmentType: string, paramName: string): string {\n switch (segmentType) {\n case 'catch-all':\n return `[...${paramName}]`;\n case 'optional-catch-all':\n return `[[...${paramName}]]`;\n default:\n return `[${paramName}]`;\n }\n}\n\n/**\n * Resolve the global schema's segmentParams into a map of bracket-keyed\n * param name → resolved Codec.\n *\n * Keys are preserved exactly as written in `app/schema.ts` so that\n * `[id]`, `[...id]`, and `[[...id]]` remain distinct entries.\n * The coercion layer in `param-coercion.ts` reconstructs bracket keys\n * from matched segment metadata to look up the correct codec.\n *\n * Input: { '[id]': z.coerce.number(), '[...slug]': codec.string }\n * Output: { '[id]': Codec<number>, '[...slug]': Codec<string> }\n */\nexport function resolveSchemaCodecs(\n segmentParams: Record<string, unknown>\n): Record<string, Codec<unknown>> {\n const resolved: Record<string, Codec<unknown>> = Object.create(null);\n\n for (const [bracketName, value] of Object.entries(segmentParams)) {\n resolved[bracketName] = resolveCodec(bracketName, value);\n }\n\n return resolved;\n}\n"],"mappings":";;;;;;AAmBA,SAAS,gBAAmB,WAAmB,QAAuC;AACpF,QAAO;EACL,MAAM,OAAyC;GAC7C,MAAM,SAAS,aAAa,QAAQ,MAAM;AAC1C,OAAI,CAAC,OAAO,OACV,QAAO,OAAO;GAEhB,MAAM,WAAW,OAAO,OAAO,KAAK,MAAM,EAAE,QAAQ,CAAC,KAAK,KAAK;AAC/D,SAAM,IAAI,MAAM,mBAAmB,UAAU,qBAAqB,WAAW;;EAE/E,UAAU,OAAyB;AACjC,OAAI,UAAU,QAAQ,UAAU,KAAA,EAAW,QAAO;AAClD,OAAI,MAAM,QAAQ,MAAM,CAAE,QAAO,MAAM,KAAK,IAAI;AAChD,UAAO,OAAO,MAAM;;EAEvB;;;;;AAMH,SAAS,aAAa,WAAmB,OAAgC;AACvE,KAAI,QAAQ,MAAM,CAAE,QAAO;AAC3B,KAAI,iBAAiB,MAAM,CAAE,QAAO,gBAAgB,WAAW,MAAM;AACrE,OAAM,IAAI,MAAM,0BAA0B,UAAU,4CAA4C;;;;;;;;;;AAWlG,SAAgB,aAAa,aAAqB,WAA2B;AAC3E,SAAQ,aAAR;EACE,KAAK,YACH,QAAO,OAAO,UAAU;EAC1B,KAAK,qBACH,QAAO,QAAQ,UAAU;EAC3B,QACE,QAAO,IAAI,UAAU;;;;;;;;;;;;;;;AAgB3B,SAAgB,oBACd,eACgC;CAChC,MAAM,WAA2C,OAAO,OAAO,KAAK;AAEpE,MAAK,MAAM,CAAC,aAAa,UAAU,OAAO,QAAQ,cAAc,CAC9D,UAAS,eAAe,aAAa,aAAa,MAAM;AAG1D,QAAO"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { o as _setGlobalRouter, u as globalRouter } from "./ssr-data-
|
|
1
|
+
import { o as _setGlobalRouter, u as globalRouter } from "./ssr-data-BP5-JJTi.js";
|
|
2
2
|
//#region src/client/router-ref.ts
|
|
3
3
|
/**
|
|
4
4
|
* Set the global router instance. Called once during bootstrap.
|
|
@@ -25,4 +25,4 @@ function getRouterOrNull() {
|
|
|
25
25
|
//#endregion
|
|
26
26
|
export { getRouterOrNull as n, setGlobalRouter as r, getRouter as t };
|
|
27
27
|
|
|
28
|
-
//# sourceMappingURL=router-ref-
|
|
28
|
+
//# sourceMappingURL=router-ref-115GHAhP.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"router-ref-
|
|
1
|
+
{"version":3,"file":"router-ref-115GHAhP.js","names":[],"sources":["../../src/client/router-ref.ts"],"sourcesContent":["// Global router reference — shared between browser-entry and client hooks.\n//\n// Delegates to client/state.ts for the actual module-level variable.\n// This ensures singleton semantics regardless of import path — all\n// callers converge on the same state.ts instance via the barrel.\n//\n// See design/18-build-system.md §\"Module Singleton Strategy\"\n\nimport type { RouterInstance } from './router.js';\nimport { globalRouter, _setGlobalRouter } from './state.js';\n\n/**\n * Set the global router instance. Called once during bootstrap.\n */\nexport function setGlobalRouter(router: RouterInstance): void {\n _setGlobalRouter(router);\n}\n\n/**\n * Get the global router instance. Throws if called before bootstrap.\n * Used by client-side hooks (usePendingNavigation, etc.)\n */\nexport function getRouter(): RouterInstance {\n if (!globalRouter) {\n throw new Error('[timber] Router not initialized. getRouter() was called before bootstrap().');\n }\n return globalRouter;\n}\n\n/**\n * Get the global router instance or null if not yet initialized.\n * Used by useRouter() methods to avoid silent failures — callers\n * can log a meaningful warning instead of silently no-oping.\n */\nexport function getRouterOrNull(): RouterInstance | null {\n return globalRouter;\n}\n\n/**\n * Reset the global router to null. Used only in tests to isolate\n * module-level state between test cases.\n * @internal\n */\nexport function resetGlobalRouter(): void {\n _setGlobalRouter(null);\n}\n"],"mappings":";;;;;AAcA,SAAgB,gBAAgB,QAA8B;AAC5D,kBAAiB,OAAO;;;;;;AAO1B,SAAgB,YAA4B;AAC1C,KAAI,CAAC,aACH,OAAM,IAAI,MAAM,8EAA8E;AAEhG,QAAO;;;;;;;AAQT,SAAgB,kBAAyC;AACvD,QAAO"}
|
|
@@ -83,4 +83,4 @@ function fromArraySchema(schema) {
|
|
|
83
83
|
//#endregion
|
|
84
84
|
export { validateSync as a, isStandardSchema as i, fromSchema as n, isCodec as r, fromArraySchema as t };
|
|
85
85
|
|
|
86
|
-
//# sourceMappingURL=schema-bridge-
|
|
86
|
+
//# sourceMappingURL=schema-bridge-o0SwFtXW.js.map
|