Conversation
Auditing csag-blueprint-web's migration onto 0.1.0 surfaced a set of places
where the app had to hold code, coerce a type or re-implement a helper because
of something this repo got slightly wrong. All of it is additive or a fix; no
existing call site has to change.
Internal dependencies published as ^0.1.0 rather than the exact version. A
consumer pinning blueprint-core exactly would, on the next release, resolve a
second copy of core beneath form-kit and api-kit while its own direct dependency
stayed behind — two versions of a package the fixed changeset group promises are
always in lockstep. workspace:* publishes the exact version.
resolveClientTranslations trusted localStorage. A corrupt or older-schema entry
holding {"data":null} resolved to null, and the consumer threw at its first
property access before TranslationGuard could reload — which is the crash two
review findings on the app PR chased to the render site. Validation now lives in
an exported readStoredTranslations, and a rejected entry is a cache miss.
isDevMode read import.meta.env. Vite injects that into app source but not into a
pre-bundled dependency, so every development diagnostic in the form kit went
silent the moment the kit stopped being vendored code and became the published
package it now is. It prefers process.env.NODE_ENV, which the dependency
optimizer does define and which rollup, webpack, esbuild and Jest set too.
The remaining gaps were contract shapes the app had to work around: label leaves
rejected null although a nullable backend column produces it, and the zod field
labels required a Record where a generated interface has no index signature, so
both forced a coercion at the call site that looked like a runtime guard and was
not. English copy in the error toast and the translation guard had no override.
CSS custom property names in the theming helper had to match one app's
stylesheet. The field components were unexported, and the barrel listing them
was missing two of the ten. formatMessage arrives because the only placeholder
helper here used {{key}} while the whole rest of the stack speaks {Key}, so it
matched nothing the backend emits and every consumer wrote its own; interpolate
is deprecated for one minor.
Tests cover the cache validation, both placeholder syntaxes and the label
defaults. Verified: prettier, eslint, tsc, build, 56/56 vitest, publint and attw
all clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reading process.env.NODE_ENV was the wrong fix for the silent diagnostics.
These packages build with rolldown's browser platform, which inlines that
expression at build time — the published artifact came out carrying a frozen
`"development"`, so the kit would have reported a development build to every
consumer forever.
Nor can it be fixed by reading the value at runtime: Vite substitutes the text,
it does not create a global `process`, so a browser lookup finds nothing. The
literal would have to survive our build to be replaced by the consumer's, which
means changing the build platform for all six packages.
A published package cannot work this out for itself, so it should not pretend
to. `setDevMode` lets the consumer say, from its own source where the bundler
does substitute:
setDevMode(import.meta.env.DEV)
The import.meta.env heuristic stays as the default, so consuming these packages
as source — and this repo's own tests — behave exactly as before, and an app
that never calls it gets quiet diagnostics, which is the right production
default.
This also drops the `declare global var process` block, which would have
collided with @types/node in any consumer that has it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Auditing csag-blueprint-web#309, the PR that moves the blueprint app onto these packages, produced a list of places where the app had to keep code, coerce a type or re-implement a helper because of something in here. This is that list.
Everything is additive or a fix. No existing call site has to change, which is why the changeset is a
patch— happy to make it aminorif the team reads "new exports" as deserving one.The three that are actually bugs
Lockstep is not enforced by the published manifests.
blueprint-form-kitandblueprint-api-kitdeclaredblueprint-coreasworkspace:^, which publishes as^0.1.0:The app pins core exactly and excludes the scope from its 14-day age floor. The first time a
0.1.1exists, its monthly lockfile refresh re-resolves,^0.1.0accepts the newer core, and the tree ends up with core 0.1.1 beneath form-kit and api-kit while the direct dependency stays on 0.1.0 — two copies, in a PR titled "chore: refresh lockfiles". Core holds only pure functions today so the blast radius is small, but the "all six at one version" promise in both READMEs would be quietly false.workspace:*publishes the exact version, which is what thefixedchangeset group already implies.resolveClientTranslationstrustedlocalStorage. It returnedJSON.parse(stored).datawith no check.typeof null === 'object', so a stored{"data":null}resolved tonulland the consumer threw at its first property access — during render, beforeTranslationGuardcould switch to recovery and reload. That is the crash behind two Copilot findings on the app PR; the app patched its own render site and correctly noted the root cause lives here. Validation now lives in an exportedreadStoredTranslations, and an entry that fails it is treated as a cache miss, so the caller gets itsfallbackand the guard can do its job.localStorageoutlives deployments, so an entry written against an older schema is a normal occurrence, not a corner case.Development diagnostics stopped firing once this shipped as a package.
isDevModereadimport.meta.env.DEV. Vite injects that through its import-analysis plugin, which runs over app source; a package resolved fromnode_modulesis instead pre-bundled by the dependency optimizer, which does not define it. So every development diagnostic in the form kit — notably the orphan-field warning inuseSchemaForm— went silent the moment this code stopped being vendored app source and became the published package it now is.The obvious fix is wrong, and the first commit here had it.
process.env.NODE_ENVis inlined by rolldown's browser platform at our build time, so the built artifact came out carrying a frozen"development"— every consumer would have been told it was a dev build, forever. Reading it at runtime does not work either: Vite substitutes the text rather than creating a globalprocess, so a browser lookup finds nothing. Making the literal survive our build would mean changingplatformfor all six packages.A published package cannot determine this for itself, so it no longer pretends to.
setDevModelets the consumer declare it, from its own source where the bundler does substitute:The old heuristic remains the default, so consuming these packages as source is unchanged, and an app that never calls it gets quiet diagnostics — the right production default.
Contract shapes the app had to work around
Label leaves rejected
null. The backend marks the unsaved-changes copy nullable, so the generated client producesstring | null, so__root.tsxcarries four?? undefinedcoercions that look like runtime guards and are not —mergeLabelshas always discarded non-strings.PartialFormKitLabelsnow acceptsstring | null | undefinedat every leaf.Zod field labels required a
Record. TypeScript gives interfaces no implicit index signature, so the generatedTranslationValuesFieldsValuescould not be passed and the app spread it into a fresh object purely to change its declared type.setZodValidationMessagesis generic over itsfieldsargument via a newZodFieldNamesOf<T>; the redundant double cast insideresolveFieldNameis gone too.English copy had no override.
setErrorNotificationLabelscovers the error toast's five strings. A module-level setter rather than a prop because that toast is raised from axios interceptors and query-cache handlers, which do not sit in the React tree holding the translations — the same bridge the zod kit already uses.TranslationGuardtakes alabelsprop; its doc comment spells out that these cannot come from the translations it exists to recover from.CSS custom property names were fixed.
buildTenantCssVarsemitted--accentand friends, the names one app'sstyles.csshappens to declare. It takes an optionalvarNames; defaults unchanged.Things the app was holding that belong here
The field components are exported.
TextInputField,SelectFieldand the rest are named exports now. The registry itself stays fixed, so this does not yet let a consumer add an eleventhfield.*member — but it removes the reasonfields/index.tsexisted as a dead barrel, and that barrel was also missing two of the ten it claimed to re-export. Both READMEs now state the rule the app discovered the hard way: an app-coupled field is a plain component overuseFieldContext, rendered as the body ofform.AppField.formatMessage. The only placeholder helper here used{{key}}. The backend stores validation copy with{FieldName},{Min},{Max}, and this repo's own zod error map fills single braces — sointerpolatematched nothing the stack emits, the app had zero usages of it and twelve of its ownformatMessage. Added with the right syntax;interpolateis deprecated and kept for one minor.Not in this PR
fieldComponentsextensible is what would finally let the app's dropzoneFileInputFieldcome home. It needs a design pass, and the plainFileInputFieldthis package still registers asfield.FileInputshould be decided at the same time — it is stale relative to the app's and reachable by any other consumer.mainhas no protection andpkg.pr.newis still parked, so there is no way to test a change here against the app before a version is permanently burned. Worth closing before the next wave.Verification
prettier --check,eslint,tsc --noEmitacross all six,pnpm build,pnpm test(56/56, up from 45),publint --strictandattw --profile esm-onlyall clean.New tests: cache validation including the
data: nullregression, both placeholder syntaxes including that they do not collide, and the label defaults.🤖 Generated with Claude Code