mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-03 01:18:38 +00:00
fix(create): prevent customizer reset when template query param is set (#11074)
* fix(create): prevent customizer reset when template query param is set Use shallow nuqs updates and remove racing picker effects so selections persist on /create?template=start. Sync preset to URL once across hook instances and add regression tests for preset URL encoding. Fixes #11060 * fix(create): apply undo/redo via shallow preset update Undo/redo went through router.replace, which triggers a full server navigation that resets the preset on prod (#11060). Route it through the shared shallow search-params update instead, and keep the useSearchParams reader so the transient values nuqs emits mid-update are not recorded as phantom history entries. * fix(create): satisfy typecheck and prettier in search-params test Add the required `template` field to the first buildPresetUrlUpdate fixture so it matches DesignSystemSearchParams, and apply prettier formatting to search-params.ts and its test (both were failing format:check). --------- Co-authored-by: shadcn <m@shadcn.com>
This commit is contained in:
committed by
GitHub
parent
70afde8358
commit
a409271270
@@ -46,12 +46,6 @@ export function ChartColorPicker({
|
||||
[params.chartColor]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentChartColor && availableChartColors.length > 0) {
|
||||
setParams({ chartColor: availableChartColors[0].name })
|
||||
}
|
||||
}, [currentChartColor, availableChartColors, setParams])
|
||||
|
||||
return (
|
||||
<div className="group/picker relative">
|
||||
<Picker>
|
||||
|
||||
@@ -38,12 +38,6 @@ export function ThemePicker({
|
||||
[params.theme]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentTheme && themes.length > 0) {
|
||||
setParams({ theme: themes[0].name })
|
||||
}
|
||||
}, [currentTheme, themes, setParams])
|
||||
|
||||
return (
|
||||
<div className="group/picker relative">
|
||||
<Picker>
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { Suspense } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
|
||||
import {
|
||||
useDesignSystemSearchParams,
|
||||
type DesignSystemSearchParams,
|
||||
} from "@/app/(app)/create/lib/search-params"
|
||||
|
||||
type HistoryContextValue = {
|
||||
canGoBack: boolean
|
||||
@@ -15,6 +20,9 @@ const HistoryContext = React.createContext<HistoryContextValue | null>(null)
|
||||
|
||||
// Reads useSearchParams() in its own Suspense boundary so the
|
||||
// provider never blanks out children while search params resolve.
|
||||
// useSearchParams reflects the *settled* preset, which coalesces the
|
||||
// transient values nuqs emits mid-update — reading nuqs state directly
|
||||
// here would record those transients as phantom history entries.
|
||||
function PresetSync({
|
||||
onPresetChange,
|
||||
}: {
|
||||
@@ -31,7 +39,11 @@ function PresetSync({
|
||||
}
|
||||
|
||||
export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
// Write through the shared search-params hook (shallow) instead of
|
||||
// router.replace. router.replace triggers a full server navigation that
|
||||
// resets the preset on prod — the same failure the customizer avoids by
|
||||
// going shallow (#11060).
|
||||
const [, setParams] = useDesignSystemSearchParams()
|
||||
|
||||
const [preset, setPreset] = React.useState("")
|
||||
|
||||
@@ -81,17 +93,16 @@ export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
||||
indexRef.current = nextIndex
|
||||
setIndex(nextIndex)
|
||||
|
||||
const targetPreset = entriesRef.current[nextIndex]
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (targetPreset) {
|
||||
params.set("preset", targetPreset)
|
||||
} else {
|
||||
params.delete("preset")
|
||||
}
|
||||
const pathname = window.location.pathname
|
||||
const query = params.toString()
|
||||
router.replace(query ? `${pathname}?${query}` : pathname)
|
||||
}, [router])
|
||||
// The first history entry is "" (no preset in the URL); null clears the
|
||||
// param to restore that state. nuqs accepts null to clear a key, which the
|
||||
// wrapper's input type does not model.
|
||||
setParams(
|
||||
{
|
||||
preset: entriesRef.current[nextIndex] || null,
|
||||
} as Partial<DesignSystemSearchParams>,
|
||||
{ history: "replace" }
|
||||
)
|
||||
}, [setParams])
|
||||
|
||||
const goForward = React.useCallback(() => {
|
||||
if (indexRef.current >= maxIndexRef.current) {
|
||||
@@ -103,17 +114,16 @@ export function HistoryProvider({ children }: { children: React.ReactNode }) {
|
||||
indexRef.current = nextIndex
|
||||
setIndex(nextIndex)
|
||||
|
||||
const targetPreset = entriesRef.current[nextIndex]
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (targetPreset) {
|
||||
params.set("preset", targetPreset)
|
||||
} else {
|
||||
params.delete("preset")
|
||||
}
|
||||
const pathname = window.location.pathname
|
||||
const query = params.toString()
|
||||
router.replace(query ? `${pathname}?${query}` : pathname)
|
||||
}, [router])
|
||||
// The first history entry is "" (no preset in the URL); null clears the
|
||||
// param to restore that state. nuqs accepts null to clear a key, which the
|
||||
// wrapper's input type does not model.
|
||||
setParams(
|
||||
{
|
||||
preset: entriesRef.current[nextIndex] || null,
|
||||
} as Partial<DesignSystemSearchParams>,
|
||||
{ history: "replace" }
|
||||
)
|
||||
}, [setParams])
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
|
||||
53
apps/v4/app/(app)/create/lib/search-params.test.ts
Normal file
53
apps/v4/app/(app)/create/lib/search-params.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { DEFAULT_CONFIG } from "@/registry/config"
|
||||
|
||||
import { buildPresetUrlUpdate } from "./search-params"
|
||||
|
||||
describe("buildPresetUrlUpdate", () => {
|
||||
it("encodes design system params into preset and clears individual keys", () => {
|
||||
const update = buildPresetUrlUpdate({
|
||||
...DEFAULT_CONFIG,
|
||||
preset: "b0",
|
||||
template: "next",
|
||||
item: "preview-02",
|
||||
size: 100,
|
||||
custom: false,
|
||||
})
|
||||
|
||||
expect(update.preset).toBeTypeOf("string")
|
||||
expect(update.style).toBeNull()
|
||||
expect(update.theme).toBeNull()
|
||||
expect(update.baseColor).toBeNull()
|
||||
})
|
||||
|
||||
it("preserves template when syncing preset from ?template=start", () => {
|
||||
const update = buildPresetUrlUpdate({
|
||||
...DEFAULT_CONFIG,
|
||||
preset: "b0",
|
||||
template: "start",
|
||||
item: "preview-02",
|
||||
size: 100,
|
||||
custom: false,
|
||||
})
|
||||
|
||||
expect(update.template).toBe("start")
|
||||
expect(update.preset).toBeTypeOf("string")
|
||||
})
|
||||
|
||||
it("applies non-design-system updates from resolvedUpdates", () => {
|
||||
const update = buildPresetUrlUpdate(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
preset: "b0",
|
||||
template: "next",
|
||||
item: "preview-02",
|
||||
size: 100,
|
||||
custom: false,
|
||||
},
|
||||
{ template: "vite" }
|
||||
)
|
||||
|
||||
expect(update.template).toBe("vite")
|
||||
})
|
||||
})
|
||||
@@ -132,6 +132,9 @@ const NON_DESIGN_SYSTEM_KEYS = [
|
||||
"custom",
|
||||
] as const
|
||||
|
||||
// Shared across hook instances so only one mount effect encodes the initial URL.
|
||||
let hasSyncedPresetToUrl = false
|
||||
|
||||
export const loadDesignSystemSearchParams = createLoader(
|
||||
designSystemSearchParams
|
||||
)
|
||||
@@ -235,13 +238,42 @@ function resolvePresetParams(
|
||||
return normalizeDesignSystemParams(rawParams)
|
||||
}
|
||||
|
||||
export function buildPresetUrlUpdate(
|
||||
merged: DesignSystemSearchParams,
|
||||
resolvedUpdates: Partial<DesignSystemSearchParams> = {}
|
||||
) {
|
||||
const code = getPresetCode(merged)
|
||||
const rawUpdate: Record<string, unknown> = { preset: code }
|
||||
|
||||
for (const key of DESIGN_SYSTEM_KEYS) {
|
||||
rawUpdate[key] = null
|
||||
}
|
||||
|
||||
for (const key of NON_DESIGN_SYSTEM_KEYS) {
|
||||
if (key === "preset") {
|
||||
continue
|
||||
}
|
||||
|
||||
rawUpdate[key] =
|
||||
key in resolvedUpdates
|
||||
? (resolvedUpdates as Record<string, unknown>)[key]
|
||||
: merged[key]
|
||||
}
|
||||
|
||||
return rawUpdate
|
||||
}
|
||||
|
||||
// Wraps nuqs useQueryStates with transparent preset encoding/decoding.
|
||||
// - Reads: if ?preset=CODE is in the URL, decodes it and returns individual values.
|
||||
// - Writes: when design system params are set, encodes them into a preset code.
|
||||
//
|
||||
// Default options use shallow: true so picker selections do not trigger a full
|
||||
// Next.js server navigation. This prevents the customizer panel from flickering
|
||||
// or resetting while the URL update propagates (#10910, #11060).
|
||||
export function useDesignSystemSearchParams(options: Options = {}) {
|
||||
const searchParams = useSearchParams()
|
||||
const [rawParams, rawSetParams] = useQueryStates(designSystemSearchParams, {
|
||||
shallow: false,
|
||||
shallow: true,
|
||||
history: "push",
|
||||
...options,
|
||||
})
|
||||
@@ -257,6 +289,19 @@ export function useDesignSystemSearchParams(options: Options = {}) {
|
||||
paramsRef.current = params
|
||||
}, [params])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasSyncedPresetToUrl || searchParams.has("preset")) {
|
||||
return
|
||||
}
|
||||
|
||||
hasSyncedPresetToUrl = true
|
||||
|
||||
const merged = normalizeDesignSystemParams(paramsRef.current)
|
||||
void rawSetParams(buildPresetUrlUpdate(merged) as RawSetParamsInput, {
|
||||
history: "replace",
|
||||
})
|
||||
}, [rawSetParams, searchParams])
|
||||
|
||||
type RawSetParamsInput = Parameters<typeof rawSetParams>[0]
|
||||
|
||||
const setParams = React.useCallback(
|
||||
@@ -286,24 +331,10 @@ export function useDesignSystemSearchParams(options: Options = {}) {
|
||||
...paramsRef.current,
|
||||
...resolvedUpdates,
|
||||
})
|
||||
// Encode design system fields into a preset code.
|
||||
// Cast needed: merged values may include null from nuqs resets,
|
||||
// but encodePreset handles missing values by falling back to defaults.
|
||||
const code = getPresetCode(merged)
|
||||
// Build update: set preset, clear individual DS params from URL.
|
||||
const rawUpdate: Record<string, unknown> = { preset: code }
|
||||
for (const key of DESIGN_SYSTEM_KEYS) {
|
||||
rawUpdate[key] = null
|
||||
}
|
||||
|
||||
// Pass through non-DS params that were explicitly in the update.
|
||||
for (const key of NON_DESIGN_SYSTEM_KEYS) {
|
||||
if (key in resolvedUpdates) {
|
||||
rawUpdate[key] = (resolvedUpdates as Record<string, unknown>)[key]
|
||||
}
|
||||
}
|
||||
|
||||
return rawSetParams(rawUpdate as RawSetParamsInput, setOptions)
|
||||
return rawSetParams(
|
||||
buildPresetUrlUpdate(merged, resolvedUpdates) as RawSetParamsInput,
|
||||
setOptions
|
||||
)
|
||||
},
|
||||
[rawSetParams]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user