From e216b7a2b0093a6d58ddbde8b2900d8fdb878479 Mon Sep 17 00:00:00 2001 From: interacsean Date: Wed, 21 Oct 2026 11:15:57 +1100 Subject: [PATCH 1/7] refactor(core): share the warn-once dev guard as useOnceWarnings Co-Authored-By: Claude Opus 5.5 --- .../core/src/components/ai-chat/ai-chat.tsx | 24 +------------------ .../components/filter-rail/filter-rail.tsx | 12 +++------- packages/core/src/hooks/use-once-warnings.ts | 22 +++++++++++++++++ 3 files changed, 26 insertions(+), 32 deletions(-) create mode 100644 packages/core/src/hooks/use-once-warnings.ts diff --git a/packages/core/src/components/ai-chat/ai-chat.tsx b/packages/core/src/components/ai-chat/ai-chat.tsx index 2dae9973b..c280def42 100644 --- a/packages/core/src/components/ai-chat/ai-chat.tsx +++ b/packages/core/src/components/ai-chat/ai-chat.tsx @@ -1,15 +1,14 @@ import { Children, isValidElement, - useEffect, useMemo, - useRef, type ComponentProps, type ReactElement, type ReactNode, } from "react"; import type { AIChatStatus } from "@/ai/use-ai-chat"; +import { useOnceWarnings } from "@/hooks/use-once-warnings"; import { cn } from "@/lib/utils"; import { Action, Actions } from "./actions"; import { AIChatContext } from "./ai-chat-context"; @@ -79,27 +78,6 @@ type AIChatProps = Omit, "children"> & { * * ``` */ -/** - * Emits each distinct message once per mounted root. - * - * The root re-renders on every streamed token, so warning inline during - * render would repeat the same line hundreds of times in a single response — - * and twice per render again under StrictMode. Deliberately not gated on - * `process.env.NODE_ENV`: this package has no other reference to `process`, - * and adding one would break consumers whose bundler does not shim it. - */ -function useOnceWarnings(warnings: readonly string[]) { - const seen = useRef>(new Set()); - - useEffect(() => { - for (const warning of warnings) { - if (seen.current.has(warning)) continue; - seen.current.add(warning); - console.warn(warning); - } - }); -} - type Regions = { header?: ReactElement; conversation?: ReactElement; diff --git a/packages/core/src/components/filter-rail/filter-rail.tsx b/packages/core/src/components/filter-rail/filter-rail.tsx index 43069ef6d..2f8229dbe 100644 --- a/packages/core/src/components/filter-rail/filter-rail.tsx +++ b/packages/core/src/components/filter-rail/filter-rail.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import { useOnceWarnings } from "@/hooks/use-once-warnings"; import { cn } from "@/lib/utils"; import type { CollectionControl, Filter } from "@/types/collection"; import { Sheet } from "../sheet"; @@ -50,14 +51,7 @@ function Provider()); - useEffect(() => { - for (const problem of findRailProblems(anySections)) { - if (warned.current.has(problem)) continue; - warned.current.add(problem); - console.warn(problem); - } - }, [anySections]); + useOnceWarnings(useMemo(() => findRailProblems(anySections), [anySections])); const owned = useMemo(() => railFields(anySections), [anySections]); const filters = anyControl.filters; diff --git a/packages/core/src/hooks/use-once-warnings.ts b/packages/core/src/hooks/use-once-warnings.ts new file mode 100644 index 000000000..6fa6afdb0 --- /dev/null +++ b/packages/core/src/hooks/use-once-warnings.ts @@ -0,0 +1,22 @@ +import { useEffect, useRef } from "react"; + +/** + * Dev guard: logs each distinct warning once per mounted component. + * + * Runs in an effect rather than inline so a component that re-renders often + * (or twice under StrictMode) doesn't repeat the same line. Deliberately not + * gated on `process.env.NODE_ENV`: this package has no other reference to + * `process`, and adding one would break consumers whose bundler does not + * shim it. + */ +export function useOnceWarnings(warnings: readonly string[]) { + const seen = useRef>(new Set()); + + useEffect(() => { + for (const warning of warnings) { + if (seen.current.has(warning)) continue; + seen.current.add(warning); + console.warn(warning); + } + }); +} From 4b01802351e2944e43d270d7ed95f4e6b2bfdfee Mon Sep 17 00:00:00 2001 From: interacsean Date: Wed, 21 Oct 2026 11:16:00 +1100 Subject: [PATCH 2/7] fix(field): warn when Field.Control renders a self-framed control Passing Select, Combobox, Autocomplete, Checkbox, Textarea or the date controls to Field.Control's render draws a second frame. Warn in the console and document which controls go directly under Field.Root. Refs tailor-inc/platform-planning#1977 Co-Authored-By: Claude Opus 5.5 --- .changeset/field-control-render-warning.md | 5 ++ docs-manifest.json | 18 +++---- docs-src/components/form.docs.outline.md | 26 ++++++---- docs-src/components/select.docs.outline.md | 11 +++++ docs-src/patterns/form-modal.docs.outline.md | 5 +- docs/components/form.md | 26 ++++++---- docs/components/select.md | 11 +++++ docs/patterns/form-modal.md | 5 +- .../core/src/components/field/field.test.tsx | 48 +++++++++++++++++++ packages/core/src/components/field/field.tsx | 45 +++++++++++++++-- 10 files changed, 167 insertions(+), 33 deletions(-) create mode 100644 .changeset/field-control-render-warning.md diff --git a/.changeset/field-control-render-warning.md b/.changeset/field-control-render-warning.md new file mode 100644 index 000000000..465a9d07d --- /dev/null +++ b/.changeset/field-control-render-warning.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/app-shell": patch +--- + +`Field.Control` now logs a console warning when it is given an AppShell control that draws its own frame through `render`, such as `} />`. That composition renders a double border. `Select`, `Combobox`, `Autocomplete`, `Checkbox`, `Textarea`, `DateField`, `DatePicker`, and `DateRangePicker` go directly under `Field.Root` instead. The Form, Select, and form-modal docs now say which controls go where. diff --git a/docs-manifest.json b/docs-manifest.json index d77b51ac4..03272dfac 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -699,9 +699,9 @@ "symbols": ["Form", "FormProps"], "hashes": { "typeSurface": "7b374d45d53d4535", - "outline": "79c9b0f08ca38027", + "outline": "a2cfe48049a3e5ea", "snapshot": "88a0c82baaaa2a08", - "outputMd": "91d901b17aed7781", + "outputMd": "d6ec66ffc180b303", "examples": null } }, @@ -733,9 +733,9 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "3301b263f6802090", + "outline": "a39589955fe582c8", "snapshot": null, - "outputMd": "9d068e568c9f3bdc", + "outputMd": "be00d1109761b7f1", "examples": "7836309f2df327ee" } }, @@ -1209,9 +1209,9 @@ "symbols": ["Select", "SelectAsyncFetcher"], "hashes": { "typeSurface": "429710973dfa912b", - "outline": "721e8995f53f0e8f", + "outline": "3eba8d13da1df95c", "snapshot": "68fd61885280b114", - "outputMd": "ae287e2bb5abc1c6", + "outputMd": "a55198c8cc711bd8", "examples": "1ce89cec30045466" } }, @@ -1759,14 +1759,14 @@ "packages/core/skills/app-shell-patterns/references/components/dialog.md": "ec6a0ac098d1d87a", "packages/core/skills/app-shell-patterns/references/components/document-progress-card.md": "d0fb940f685f8869", "packages/core/skills/app-shell-patterns/references/components/filter-rail.md": "c6a48c4d8e8b0078", - "packages/core/skills/app-shell-patterns/references/components/form.md": "91d901b17aed7781", + "packages/core/skills/app-shell-patterns/references/components/form.md": "d6ec66ffc180b303", "packages/core/skills/app-shell-patterns/references/components/global-header-layout.md": "dd2b0da3702cf8d9", "packages/core/skills/app-shell-patterns/references/components/grid.md": "9051eb53dde4179f", "packages/core/skills/app-shell-patterns/references/components/input.md": "a3a6c9679af99d37", "packages/core/skills/app-shell-patterns/references/components/layout.md": "66696139a46fabb0", "packages/core/skills/app-shell-patterns/references/components/menu.md": "ceb4a05822538d20", "packages/core/skills/app-shell-patterns/references/components/metric-card.md": "5a1c557768c6590f", - "packages/core/skills/app-shell-patterns/references/components/select.md": "ae287e2bb5abc1c6", + "packages/core/skills/app-shell-patterns/references/components/select.md": "a55198c8cc711bd8", "packages/core/skills/app-shell-patterns/references/components/sheet.md": "1f81ebd562c50371", "packages/core/skills/app-shell-patterns/references/components/sidebar-group.md": "a36192df64f16adc", "packages/core/skills/app-shell-patterns/references/components/sidebar-item.md": "9236c03e53aa79f2", @@ -1807,7 +1807,7 @@ "packages/core/skills/app-shell-patterns/references/api/use-time-zone.md": "30c15545ab3c4954", "packages/core/skills/app-shell-patterns/references/api/use-toast.md": "30983a761bc61593", "packages/core/skills/app-shell-patterns/references/patterns/form-composer.md": "ed187ac3a0773d3d", - "packages/core/skills/app-shell-patterns/references/patterns/form-modal.md": "9d068e568c9f3bdc", + "packages/core/skills/app-shell-patterns/references/patterns/form-modal.md": "be00d1109761b7f1", "packages/core/skills/app-shell-patterns/references/patterns/form-sectioned.md": "d31cd3ae6ad0790c", "packages/core/skills/app-shell-patterns/references/patterns/form-single-page.md": "10a0f4d91a120752", "packages/core/skills/app-shell-patterns/references/patterns/form-wizard.md": "851afb4672da6673", diff --git a/docs-src/components/form.docs.outline.md b/docs-src/components/form.docs.outline.md index 08958cbd5..1bef5f943 100644 --- a/docs-src/components/form.docs.outline.md +++ b/docs-src/components/form.docs.outline.md @@ -102,14 +102,14 @@ A compound component that groups all parts of a form field and manages its valid ### Sub-components -| Sub-component | Description | -| ------------------- | --------------------------------------------------------------------------------------------- | -| `Field.Root` | Context boundary for a single field. Manages validation state. | -| `Field.Label` | Accessible label; `htmlFor` is resolved automatically via context. | -| `Field.Control` | Styled ``. Can be omitted when using a Base UI-backed AppShell component as the input. | -| `Field.Description` | Supplementary help text; automatically linked via `aria-describedby`. | -| `Field.Error` | Validation error message. Use `match` to target specific validity states. | -| `Field.Validity` | Render-prop access to the field's `ValidityState` for fully custom validation UI. | +| Sub-component | Description | +| ------------------- | -------------------------------------------------------------------------------------- | +| `Field.Root` | Context boundary for a single field. Manages validation state. | +| `Field.Label` | Accessible label; `htmlFor` is resolved automatically via context. | +| `Field.Control` | Styled text ``. Other AppShell controls go directly under `Field.Root` instead. | +| `Field.Description` | Supplementary help text; automatically linked via `aria-describedby`. | +| `Field.Error` | Validation error message. Use `match` to target specific validity states. | +| `Field.Validity` | Render-prop access to the field's `ValidityState` for fully custom validation UI. | ### Field.Root Props @@ -136,7 +136,15 @@ A compound component that groups all parts of a form field and manages its valid ### Using Another AppShell Component as the Control -`Field.Control` can be omitted when using a Base UI-backed AppShell component (e.g. `Select`, `Combobox`). The component registers itself with the `Field` context automatically, inheriting label association and validation state. +`Field.Control` is a text input. It is the control for plain text-like fields, and you can pass it a native element such as `render={