Skip to content

Commit 1a07523

Browse files
serpentbladeclaude
andcommitted
fix(16): solid lower static style="..." to object form (mirror react)
ITEM-1-RESIDUAL partial closure. Static `style="k: v"` HTML attributes now lower to JSX `style={{k: v}}` object form on the Solid target, mirroring the existing React path. The bug class: when a child component is invoked with a string-form `style="..."` attribute and the child auto-forwards via `{...attrs}`, Solid's dom-expressions `style()` helper routed the string value through `nodeStyle.cssText = value` which REPLACES the entire inline style — wiping any wrapper-set `style={{...}}` defaults. With the object-form lowering the child receives an object, Solid iterates and `setProperty`-per-key, MERGING with the wrapper's defaults instead. Net effect on the ThemedButtonConsumer · solid matrix VR cell: unchanged at 3 108 px diff. The wrapper's `--btn-fg: #ffffff` was already rendering white via the CSS `var(--btn-fg, #fff)` fallback, so this fix moves no visible pixels. Still worth landing — future engine wrappers that depend on the precise inline-style cascade (rather than CSS-var fallback) now work correctly on Solid, matching React. Files: - packages/targets/solid/src/emit/emitTemplateAttribute.ts — refactored `lowerStringLiteralStyle` to take `(sourceLoc, literal)` (mirror react sibling) and added the bare-`style="..."` static path next to the existing `:style="'literal'"` binding-path call site - tests/dist-parity/fixtures/ThemedButtonConsumer.solid.tsx — rebless (sole consumer of static `style="..."` in the dist-parity slate) - matrix.spec.ts PHASE_14_1_FOLLOWUP comment — updated to reflect the closure attempt + remaining ~20 px width residual (unaffected by this fix; ruled out the style-clobber theory) Verification: - turbo run typecheck --force --continue: 47/47 green - turbo run test --continue: 46/46 green (target-solid 188/188, dist-parity 512/512) - tools/ci-repro/vr.sh -g ThemedButtonConsumer.*solid: cell still fails at 3 108 px (gated); themed-button × solid DOM-assertion cells remain green - Full VR matrix (cached run): 241 passed / 13 skipped / 0 failed — no new regressions The remaining 20 px width drift is a different, narrower issue. Carried into a separate post-Phase-16 investigation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 14508e6 commit 1a07523

3 files changed

Lines changed: 51 additions & 20 deletions

File tree

packages/targets/solid/src/emit/emitTemplateAttribute.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ function cssPropToStyleKey(prop: string): string {
415415
* PostCSS throw).
416416
*/
417417
function lowerStringLiteralStyle(
418-
attr: Extract<AttributeBinding, { kind: 'binding' }>,
418+
sourceLoc: AttributeBinding['sourceLoc'],
419419
literal: string,
420420
): { jsx: string; diagnostics: Diagnostic[] } {
421421
const diagnostics: Diagnostic[] = [];
@@ -432,9 +432,9 @@ function lowerStringLiteralStyle(
432432
code: RozieErrorCode.STYLE_PARSE_ERROR,
433433
severity: 'error',
434434
message:
435-
`Could not parse inline \`:style\` string ${JSON.stringify(literal)}: ` +
435+
`Could not parse inline \`style\` string ${JSON.stringify(literal)}: ` +
436436
`${err instanceof Error ? err.message : String(err)}`,
437-
loc: attr.sourceLoc,
437+
loc: sourceLoc,
438438
});
439439
return { jsx: 'style={{}}', diagnostics };
440440
}
@@ -448,7 +448,7 @@ function lowerStringLiteralStyle(
448448
message:
449449
`\`!important\` on \`${decl.prop}\` is dropped by Solid's style-object form. ` +
450450
`Solid silently ignores \`!important\` in inline style objects.`,
451-
loc: attr.sourceLoc,
451+
loc: sourceLoc,
452452
});
453453
}
454454
props.push(`${keyOut}: ${JSON.stringify(decl.value)}`);
@@ -475,6 +475,24 @@ function emitNonClassAttribute(
475475
// Unknown ref — pass through as static
476476
}
477477

478+
// bare `style="k1: v1; k2: v2"` static → object form. Mirrors React's
479+
// matching emit path (Phase 14-06 ThemedButtonConsumer divergence): when a
480+
// child component is invoked with a string-form `style="..."` attribute,
481+
// the consumer's prop reaches the child as a STRING. The child's auto-
482+
// fallthrough spread (`{...attrs}`) then routes the string through Solid's
483+
// dom-expressions `style()` helper, which for string values does
484+
// `nodeStyle.cssText = value` — REPLACING the entire inline style and
485+
// wiping any wrapper-set `style={{...}}` defaults (the
486+
// `ThemedButtonConsumer · solid` matrix VR cell residual after Item 1:
487+
// wrapper's `--btn-fg: #ffffff` survived only via the `var(...)` fallback,
488+
// not via the inline style). Converting the string to an object literal
489+
// here ensures the child receives `props.style` as an object; Solid's
490+
// `style()` helper then iterates and `setProperty`s per key, MERGING with
491+
// the wrapper's defaults instead of clobbering them.
492+
if (attr.kind === 'static' && attr.name === 'style') {
493+
return lowerStringLiteralStyle(attr.sourceLoc, attr.value);
494+
}
495+
478496
if (attr.kind === 'static') {
479497
const jsxName = htmlAttrToSolidName(attr.name);
480498
if (NUMERIC_HTML_ATTRS.has(attr.name.toLowerCase()) && /^-?\d+(?:\.\d+)?$/.test(attr.value)) {
@@ -493,7 +511,7 @@ function emitNonClassAttribute(
493511
// register the runtime helper import.
494512
if (attr.name === 'style') {
495513
if (t.isStringLiteral(attr.expression)) {
496-
return lowerStringLiteralStyle(attr, attr.expression.value);
514+
return lowerStringLiteralStyle(attr.sourceLoc, attr.expression.value);
497515
}
498516
if (!t.isObjectExpression(attr.expression)) {
499517
ctx.collectors.runtime.add('parseInlineStyle');

tests/dist-parity/fixtures/ThemedButtonConsumer.solid.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,13 @@ export default function ThemedButtonConsumer(_props: ThemedButtonConsumerProps):
2525
}`}</style>
2626
<>
2727
<div {...attrs} class={"themed-button-consumer" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-14b8cbaa="">
28-
<ThemedButton id="auto-btn" type="button" aria-label="Auto-fallthrough button" data-testid="auto-themed-button" style="--btn-bg: #ef4444" class={"extra-variant"} label={'Auto'} onClick={onClick} onMouseEnter={onMouseEnter} data-rozie-s-14b8cbaa="" />
28+
<ThemedButton id="auto-btn" type="button" aria-label="Auto-fallthrough button" data-testid="auto-themed-button" class={"extra-variant"} style={{ "--btn-bg": "#ef4444" }} label={'Auto'} onClick={onClick} onMouseEnter={onMouseEnter} data-rozie-s-14b8cbaa="" />
2929

30-
<ThemedButtonManual id="manual-btn" type="button" aria-label="Manual fallthrough button" data-testid="manual-themed-button" style="--btn-bg: #10b981" class={"extra-variant"} label={'Manual'} onClick={onClick} onMouseEnter={onMouseEnter} data-rozie-s-14b8cbaa="" />
30+
<ThemedButtonManual id="manual-btn" type="button" aria-label="Manual fallthrough button" data-testid="manual-themed-button" class={"extra-variant"} style={{ "--btn-bg": "#10b981" }} label={'Manual'} onClick={onClick} onMouseEnter={onMouseEnter} data-rozie-s-14b8cbaa="" />
3131

32-
<ThemedButtonListenersManual id="listeners-manual-btn" type="button" aria-label="Listeners-manual fallthrough button" data-testid="listeners-manual-themed-button" style="--btn-bg: #f59e0b" class={"extra-variant"} label={'Listeners Manual'} onClick={onClick} onMouseEnter={onMouseEnter} data-rozie-s-14b8cbaa="" />
32+
<ThemedButtonListenersManual id="listeners-manual-btn" type="button" aria-label="Listeners-manual fallthrough button" data-testid="listeners-manual-themed-button" class={"extra-variant"} style={{ "--btn-bg": "#f59e0b" }} label={'Listeners Manual'} onClick={onClick} onMouseEnter={onMouseEnter} data-rozie-s-14b8cbaa="" />
3333

34-
<ThemedButtonAllManual id="all-manual-btn" type="button" aria-label="All-manual fallthrough button" data-testid="all-manual-themed-button" style="--btn-bg: #8b5cf6" class={"extra-variant"} label={'All Manual'} onClick={onClick} onMouseEnter={onMouseEnter} data-rozie-s-14b8cbaa="" />
34+
<ThemedButtonAllManual id="all-manual-btn" type="button" aria-label="All-manual fallthrough button" data-testid="all-manual-themed-button" class={"extra-variant"} style={{ "--btn-bg": "#8b5cf6" }} label={'All Manual'} onClick={onClick} onMouseEnter={onMouseEnter} data-rozie-s-14b8cbaa="" />
3535
</div>
3636
</>
3737
</>

tests/visual-regression/specs/matrix.spec.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -376,17 +376,30 @@ const PHASE_14_1_FOLLOWUP = new Set<string>([
376376
// (ratio 0.13). The 5 themed-button.spec.ts × solid DOM-assertion cells
377377
// now pass (see PHASE_14_1_FOLLOWUP_TARGETS in that spec — solid ungated).
378378
//
379-
// RESIDUAL — Cell renders at 454×52 vs 474×52 baseline (~20px width drift,
380-
// consistent ~5px per inter-button gap). Root cause unconfirmed; candidates:
381-
// (a) wrapper's `style={{...}}` defaults clobbered by consumer-passed
382-
// string-form `style="…"` via Solid's `nodeStyle.cssText =` path —
383-
// Agent S adjacent bug #2;
384-
// (b) inter-element whitespace handling between consumer's flex-child
385-
// <ThemedButton*> invocations rendering as anonymous flex items
386-
// differently in Solid vs the Vue baseline;
387-
// (c) Solid-unique inline `<style>{...}</style>` emit interacting with
388-
// flex layout in a way the other 5 targets' CSS pipelines avoid.
389-
// Carried as Item-1-residual into a follow-up post-Phase-16 sweep.
379+
// Item-1-residual closure attempt 1 (2026-05-24): the static-style →
380+
// object-form lowering (mirror of React's existing path) was added to
381+
// `packages/targets/solid/src/emit/emitTemplateAttribute.ts`. Consumer's
382+
// `style="--btn-bg: #ef4444"` now lands as `style={{"--btn-bg": "#ef4444"}}`
383+
// on the child invocation, so Solid's `style()` helper iterates and
384+
// `setProperty`-per-key instead of `cssText`-replacing — wrapper's
385+
// `--btn-fg: #ffffff` is now preserved on the inline style (correctness
386+
// improvement). VR diff is unchanged at 3 108 px because the wrapper's
387+
// `--btn-fg` was ALREADY rendering white via the CSS `var(--btn-fg, #fff)`
388+
// fallback, so the fix moves no visible pixels. Still worth landing —
389+
// future wrappers that depend on the precise inline-style cascade
390+
// (rather than CSS fallback) now work correctly on Solid.
391+
//
392+
// RESIDUAL — Cell still 454×52 vs 474×52 baseline (~20 px width drift,
393+
// unchanged). Static analysis ruled out the style-clobber theory; the
394+
// candidates that remain are subtler cross-target rendering nuances:
395+
// - inter-element whitespace between flex children rendering
396+
// differently in Solid vs Vue (the baseline);
397+
// - Solid-unique inline `<style>{...}</style>` emit affecting flex
398+
// layout (the only target that emits styles inline as a sibling);
399+
// - per-target Linux Chromium font-kerning at text-node boundaries
400+
// (memory `feedback_vr_macos_text_node_kerning` suggests Linux
401+
// kerns identically, but a single specific case may not).
402+
// None are blocking; carried into a deeper post-Phase-16 investigation.
390403
'ThemedButtonConsumer::solid',
391404
// Pre-Phase-16 cleanup Item 2 PARTIAL closure (2026-05-23). The original
392405
// gate hypothesis was correct: Svelte uses native class-hash CSS scoping

0 commit comments

Comments
 (0)