Skip to content

fix(static-config): treat absent keys as NonStatic when object has spreads#788

Merged
branchseer merged 7 commits intomainfrom
fix/static-config-spread-last
Mar 12, 2026
Merged

fix(static-config): treat absent keys as NonStatic when object has spreads#788
branchseer merged 7 commits intomainfrom
fix/static-config-spread-last

Conversation

@branchseer
Copy link
Copy Markdown
Member

Summary

Fixes a correctness bug in the static vite config extractor: when the config object contained a spread (...base) or computed key ([k]: v), any field not explicitly declared after it was left absent from the result map — and the consumer treated absent as "field definitely doesn't exist", skipping NAPI entirely even when the spread might have introduced that field.

Root cause

export default defineConfig({ ...base })
// `run` absent from map → consumer returned Ok(None), no NAPI
// But `base` might contain `run: { cacheScripts: true }` → silent misconfiguration

Fix

Introduce FieldMap (opaque struct) backed by a FieldMapInner enum with two variants:

  • Closed — no spreads or computed keys; map is exhaustive; get() returns None for absent keys (field definitely absent).
  • Open — had at least one spread or computed key; get() returns Some(NonStatic) for absent keys (may exist via the spread); only Json values for keys explicitly declared after the last spread are stored.

When a spread is encountered, the pre-spread accumulator is cleared (not marked NonStatic one by one — those entries would all be redundant in an Open map since absent already returns NonStatic).

Fields explicitly declared after the last spread are still extractable as Json.

Test coverage

  • spread_only: { ...base }get("run") = Some(NonStatic) (was wrongly None)
  • spread_then_explicit_run: { ...base, run: { cacheScripts: true } }get("run") = Some(Json({...})) (post-spread still extracted)
  • no_spread_absent_is_none: { plugins: [] }get("run") = None (Closed, definitively absent)
  • Updated existing spread/computed tests to match new semantics

53 unit tests, all passing.

🤖 Generated with Claude Code

@branchseer branchseer requested a review from fengmk2 March 12, 2026 03:11
@fengmk2
Copy link
Copy Markdown
Member

fengmk2 commented Mar 12, 2026

@branchseer let's fix tests and merge, I want to release a new version.

@netlify
Copy link
Copy Markdown

netlify Bot commented Mar 12, 2026

Deploy Preview for viteplus-staging canceled.

Name Link
🔨 Latest commit ff90e52
🔍 Latest deploy log https://app.netlify.com/projects/viteplus-staging/deploys/69b2330a5c44f70008828102

@branchseer
Copy link
Copy Markdown
Member Author

@branchseer let's fix tests and merge, I want to release a new version.

@fengmk2 Fix pushed. Let's see how it goes.

branchseer and others added 6 commits March 12, 2026 11:26
…reads

Previously, keys absent from the extracted field map were always treated as
"field definitely doesn't exist" — but this was wrong when the object contained
spread or computed-key properties, since the spread may introduce any key.

Introduce `FieldMap` (opaque struct) backed by a `FieldMapInner` enum:

- `Closed(FxHashMap<Box<str>, FieldValue>)` — no spreads or computed keys;
  the map is exhaustive and absent keys are definitively absent.
- `Open(FxHashMap<Box<str>, serde_json::Value>)` — at least one spread or
  computed-key property was present; only Json values for keys explicitly
  declared *after* the last such entry are stored; `get()` returns
  `Some(NonStatic)` for any other key.

When a spread or computed key is encountered the pre-spread accumulator is
cleared (entries would all be NonStatic anyway, implied by the Open variant).
Only Json values are stored in the Open map — NonStatic is redundant there.

Fixes: `{ ...base }` where `run` is absent from the map but may exist in
`base` — the consumer now correctly falls back to NAPI instead of silently
treating `run` as non-existent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
is_empty was only used in two tests and had no production callers.
The same assertions are expressed with get("run").is_none().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously, `extract_object_fields` left keys absent from the result map
when a spread (`...base`) or computed key was present. The consumer
treated absent as "field definitively doesn't exist", silently skipping
NAPI even though the spread may have introduced the field.

Fix by introducing an opaque `FieldMap` type backed by two internal
variants:

- `Closed` — no spreads/computed keys; absent → `None` (field doesn't exist)
- `Open` — had at least one spread/computed key; absent → `Some(NonStatic)`
  (field may exist via the spread, NAPI fallback triggered)

`StaticConfig` is now just `FieldMap` (no `Option` wrapper); the
"not analyzable" case is represented by `FieldMap::unanalyzable()` (Open
empty map), which causes every key lookup to return `Some(NonStatic)` and
trigger NAPI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the dual-map approach (closed + open + has_unknown_keys flag)
with a single `FieldMapInner` that transitions from `Closed` to `Open`
on the first spread or computed key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace `pub type StaticConfig = FieldMap` with `FieldMap` directly —
  the alias added no semantic value and required extra documentation to
  compensate for what the type itself already expresses.
- Pre-size the HashMap in `parse_json_config` with the known object length
  to avoid growth reallocations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@branchseer branchseer force-pushed the fix/static-config-spread-last branch from aaa92e7 to 242af9c Compare March 12, 2026 03:26
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@branchseer branchseer merged commit 25b23d8 into main Mar 12, 2026
21 checks passed
@branchseer branchseer deleted the fix/static-config-spread-last branch March 12, 2026 03:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants