Skip to content

Commit 4d269e8

Browse files
committed
fix(runtime-solid): expander depth-indent padding-left reaches DOM (LB6 SEAM 3)
Root cause (vs solid-js@1.9.x web.cjs style()): Solid applies object-form `style` via CSSStyleDeclaration.setProperty(key,…), which requires KEBAB-case property names. parseInlineStyle camelCased declarations (style-to-js reactCompat), so a dynamic :style returning 'padding-left:1.75rem' became { paddingLeft } -> setProperty('paddingLeft') no-op -> depth-indent dropped (single-word props like overflow survived; multi-word like padding-left did not). - parseInlineStyle now passes a CSS STRING through verbatim -> Solid's style() routes it to node.style.cssText where the browser parses every kebab decl - object inputs (custom-property maps) still pass through unchanged - emitted leaves byte-identical (still call parseInlineStyle); runtime-only fix - red-first behavioral proof mirrors Solid's exact style() application (happy-dom) - remove obsolete style-to-js<->postcss parity test (it cemented the camelCase bug)
1 parent 80a65f0 commit 4d269e8

4 files changed

Lines changed: 126 additions & 116 deletions

File tree

packages/runtime/solid/src/__tests__/parseInlineStyle.parity.test.ts

Lines changed: 0 additions & 56 deletions
This file was deleted.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* LB6 SEAM 3 — Solid expander depth-indent reaches the DOM.
3+
*
4+
* Root cause (pinned against solid-js@1.9.12 web.cjs `style()` L294-298): Solid
5+
* applies an object-form `style` prop by iterating keys into
6+
* `CSSStyleDeclaration.setProperty(key, value)`. `setProperty` requires a
7+
* KEBAB-case CSS property name — `setProperty('paddingLeft', …)` is a silent
8+
* no-op. `parseInlineStyle` previously camelCased declarations (via style-to-js
9+
* `reactCompat: true`), so a dynamic `:style="bodyCellStyle(row,col)"` returning
10+
* `'padding-left:1.75rem'` became `{ paddingLeft: '1.75rem' }` → dropped on
11+
* Solid. Single-word props (`overflow`) survived (camelCase == kebab); the
12+
* data-table expander depth-indent (`padding-left`) did not.
13+
*
14+
* The fix: `parseInlineStyle` passes a CSS STRING through verbatim so Solid's
15+
* `style()` helper routes it to `cssText` (the browser's CSS parser handles the
16+
* kebab property correctly). This test reproduces Solid's exact `style()`
17+
* application (string → cssText, object → per-key setProperty) and asserts the
18+
* depth-indent `padding-left` actually reaches the element.
19+
*/
20+
import { describe, it, expect } from 'vitest';
21+
import { parseInlineStyle } from '../parseInlineStyle.js';
22+
23+
/** Mirror solid-js/web `style(node, value)`: string → cssText, object → setProperty per key. */
24+
function applySolidStyle(el: HTMLElement, value: ReturnType<typeof parseInlineStyle>): void {
25+
if (typeof value === 'string') {
26+
el.style.cssText = value;
27+
return;
28+
}
29+
for (const key in value) {
30+
const v = (value as Record<string, string>)[key];
31+
if (v != null) el.style.setProperty(key, String(v));
32+
}
33+
}
34+
35+
describe('parseInlineStyle → Solid style() application (LB6 SEAM 3)', () => {
36+
it('a multi-word `padding-left` declaration reaches the DOM the way Solid applies it', () => {
37+
const el = document.createElement('div');
38+
applySolidStyle(el, parseInlineStyle('padding-left:1.75rem'));
39+
expect(el.style.getPropertyValue('padding-left')).toBe('1.75rem');
40+
});
41+
42+
it('the expander bodyCellStyle shape (pin style + depth pad) applies both declarations', () => {
43+
const el = document.createElement('div');
44+
applySolidStyle(el, parseInlineStyle('position:sticky;left:0px;padding-left:3.25rem'));
45+
expect(el.style.getPropertyValue('position')).toBe('sticky');
46+
expect(el.style.getPropertyValue('padding-left')).toBe('3.25rem');
47+
});
48+
49+
it('a CSS custom-property object still applies (object-input passthrough preserved)', () => {
50+
const el = document.createElement('div');
51+
applySolidStyle(el, parseInlineStyle({ '--rozie-fill': '50%' }));
52+
expect(el.style.getPropertyValue('--rozie-fill')).toBe('50%');
53+
});
54+
55+
it('null / empty input is a no-op (no crash)', () => {
56+
const el = document.createElement('div');
57+
applySolidStyle(el, parseInlineStyle(null));
58+
applySolidStyle(el, parseInlineStyle(''));
59+
expect(el.getAttribute('style') ?? '').toBe('');
60+
});
61+
});

packages/runtime/solid/src/__tests__/parseInlineStyle.test.ts

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,39 @@
11
/**
2-
* Quick-task 260520-8iu Task 1 — parseInlineStyle unit tests (Solid runtime).
2+
* parseInlineStyle unit tests (Solid runtime).
33
*
4-
* Covers the Spike 004 string-form `:style` runtime-helper behavior:
5-
* style-to-js-driven declaration parse, kebab→camel key conversion,
6-
* `!important` preservation, custom-property / vendor-prefix handling,
7-
* quoted-semicolon resilience, empty/whitespace + malformed guards.
8-
*
9-
* The Solid copy of parseInlineStyle.ts is byte-identical to the React
10-
* copy (per project precedent — parallel per-target runtime packages).
4+
* LB6 SEAM 3 — a CSS STRING is now passed through VERBATIM (Solid's `style()`
5+
* helper applies it via `node.style.cssText`, where the browser's CSS parser
6+
* handles every kebab-case declaration correctly). The prior style-to-js
7+
* camelCase parse was REMOVED: Solid applies object-form styles through
8+
* `CSSStyleDeclaration.setProperty(key, …)`, which silently drops a camelCased
9+
* multi-word key (`paddingLeft`) — the data-table expander depth-indent bug.
10+
* Object inputs (e.g. a `$computed` custom-property map) still pass through.
1111
*/
1212
import { describe, it, expect } from 'vitest';
1313
import { parseInlineStyle, toStyleObjectKey } from '../parseInlineStyle.js';
1414

15-
describe('parseInlineStyle (Plan 260520-8iu Task 1)', () => {
16-
it('single declaration → single object key', () => {
17-
expect(parseInlineStyle('background: red')).toEqual({ background: 'red' });
15+
describe('parseInlineStyle (LB6 SEAM 3 — string passthrough)', () => {
16+
it('a CSS string is passed through verbatim (Solid applies it via cssText)', () => {
17+
expect(parseInlineStyle('background: red')).toBe('background: red');
1818
});
1919

20-
it('kebab-case property names convert to camelCase keys', () => {
21-
expect(parseInlineStyle('background-color: blue; font-size: 12px')).toEqual({
22-
backgroundColor: 'blue',
23-
fontSize: '12px',
24-
});
20+
it('a multi-word kebab declaration is NOT camelCased (would break Solid setProperty)', () => {
21+
expect(parseInlineStyle('padding-left:1.75rem')).toBe('padding-left:1.75rem');
22+
expect(parseInlineStyle('background-color: blue; font-size: 12px')).toBe(
23+
'background-color: blue; font-size: 12px',
24+
);
2525
});
2626

27-
it('CSS custom properties pass through verbatim', () => {
28-
expect(parseInlineStyle('--custom-prop: 4px')).toEqual({ '--custom-prop': '4px' });
27+
it('CSS custom-property string passes through verbatim', () => {
28+
expect(parseInlineStyle('--custom-prop: 4px')).toBe('--custom-prop: 4px');
2929
});
3030

31-
it('vendor-prefixed property → leading-capital camelCase key', () => {
32-
expect(parseInlineStyle('-webkit-mask: url(a.png)')).toEqual({ WebkitMask: 'url(a.png)' });
31+
it('an already-built style OBJECT (custom-property map) passes through unchanged', () => {
32+
expect(parseInlineStyle({ '--custom-prop': '4px' })).toEqual({ '--custom-prop': '4px' });
3333
});
3434

35-
it('!important is preserved by appending it to the value', () => {
36-
expect(parseInlineStyle('color: red !important')).toEqual({ color: 'red !important' });
35+
it('!important survives untouched in the string', () => {
36+
expect(parseInlineStyle('color: red !important')).toBe('color: red !important');
3737
});
3838

3939
it('empty input → empty object', () => {
@@ -44,11 +44,16 @@ describe('parseInlineStyle (Plan 260520-8iu Task 1)', () => {
4444
expect(parseInlineStyle(' ')).toEqual({});
4545
});
4646

47-
it('quoted semicolons inside values survive (style-to-js, not naive split)', () => {
48-
expect(parseInlineStyle('content: "a;b"')).toEqual({ content: '"a;b"' });
47+
it('null / undefined → empty object', () => {
48+
expect(parseInlineStyle(null)).toEqual({});
49+
expect(parseInlineStyle(undefined)).toEqual({});
50+
});
51+
52+
it('quoted semicolons inside values survive (string is handed to the browser parser intact)', () => {
53+
expect(parseInlineStyle('content: "a;b"')).toBe('content: "a;b"');
4954
});
5055

51-
it('malformed style string does NOT throw — returns {} or partial object', () => {
56+
it('malformed style string does NOT throw (passed through for the browser to tolerate)', () => {
5257
expect(() => parseInlineStyle('color: ;;; }{[[')).not.toThrow();
5358
});
5459
});

packages/runtime/solid/src/parseInlineStyle.ts

Lines changed: 35 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,32 +11,29 @@
1111
* :style="'opacity: ' + (cond ? '0.5' : '1') + '; ...'"
1212
* :style="someStringProp"
1313
*
14-
* Delegates to `style-to-js` (a ~1 KB inline-style parser built on
15-
* `inline-style-parser`) invoked with `{ reactCompat: true }`. That single
16-
* call subsumes both the declaration parse and the kebab→camel key
17-
* conversion this helper used to perform by hand:
18-
* - parses the gotchas a naive split-on-`;` misses — quoted strings with
19-
* semicolons (`content: 'foo;bar'`), `url(data:…;base64,…)` data URIs,
20-
* inline comments, `!important`;
21-
* - camelCases keys, capitalizing vendor prefixes the way React/Solid
22-
* style objects expect (`-webkit-mask` → `WebkitMask`) and passing CSS
23-
* custom properties (`--foo`) through verbatim.
14+
* SOLID DIFFERENCE (LB6 SEAM 3) — a CSS STRING is passed through VERBATIM and
15+
* left for Solid's own `style()` runtime helper to apply. Solid applies an
16+
* object-form `style` by iterating keys into `CSSStyleDeclaration.setProperty(
17+
* key, value)`, which requires a KEBAB-case CSS property name — a camelCased
18+
* key (`paddingLeft`) is a silent no-op (verified against solid-js@1.9.x
19+
* `web.cjs` `style()`). Earlier this helper camelCased declarations (via
20+
* `style-to-js`'s `reactCompat: true`), so a dynamic `:style` returning
21+
* `'padding-left:1.75rem'` became `{ paddingLeft: '1.75rem' }` → DROPPED on
22+
* Solid (single-word props like `overflow` survived; the data-table expander
23+
* depth-indent `padding-left` did not). Solid's `style()` routes a STRING to
24+
* `node.style.cssText`, where the browser's own CSS parser handles every
25+
* declaration correctly — so passing the string through is both simpler and
26+
* strictly more correct than re-parsing into a camelCased object the Solid
27+
* runtime then mis-applies.
2428
*
25-
* Replaced postcss (2026-06-04). postcss was the ONLY dependency these
26-
* runtime packages leaked into a consumer bundle (~24 KB gzip) and it was
27-
* reachable solely through this helper; style-to-js is ~1.5 KB gzip and
28-
* produces byte-identical output for the inline-declaration subset. A
29-
* differential test (`parseInlineStyle.parity.test.ts`) pins the new output
30-
* to the prior postcss implementation, kept as the oracle in devDependencies.
31-
*
32-
* Crash-safety (SPEC-1): the runtime path has no diagnostic stream, so a
33-
* parse failure must never escape as an exception — `style-to-js` throws on
34-
* some malformed input, so the call is wrapped to degrade to `{}`.
29+
* An already-built style OBJECT (a `$computed` returning a CSS-custom-property
30+
* map such as `{ '--rozie-slider-fill-start': '50%' }`) is passed through
31+
* unchanged — Solid's `setProperty` handles custom properties regardless of
32+
* casing.
3533
*
3634
* @public — runtime API consumed by emitted .tsx files.
3735
*/
3836
import type { JSX } from 'solid-js';
39-
import styleToJS from 'style-to-js';
4037

4138
const KEBAB_TO_CAMEL_CACHE = new Map<string, string>();
4239

@@ -48,9 +45,12 @@ const KEBAB_TO_CAMEL_CACHE = new Map<string, string>();
4845
* --custom-prop → --custom-prop (CSS custom properties pass through)
4946
* font-size → fontSize
5047
*
51-
* Retained as a public export (and used by the differential parity test's
52-
* oracle); the main `parseInlineStyle` path now gets key conversion from
53-
* `style-to-js` directly.
48+
* Retained as a public kebab→camel utility. NOTE (LB6 SEAM 3): the
49+
* `parseInlineStyle` path no longer camelCases at all — a CSS string is handed
50+
* to Solid's `style()` (cssText) verbatim, so this helper is no longer on that
51+
* path. It stays exported for tooling / consumers that key a Solid style object
52+
* directly. (Solid's `setProperty` actually wants KEBAB keys, so prefer the raw
53+
* CSS property name for Solid style objects — this camelCaser is React-shaped.)
5454
*/
5555
export function toStyleObjectKey(prop: string): string {
5656
const cached = KEBAB_TO_CAMEL_CACHE.get(prop);
@@ -81,28 +81,28 @@ export function toStyleObjectKey(prop: string): string {
8181
*
8282
* A dynamic `:style` binding's runtime value is NOT statically known to the
8383
* emitter, so this helper accepts the union the author may produce:
84-
* - a CSS string (`'opacity: 0.5; color: red'`) → parsed via `style-to-js`;
84+
* - a CSS string (`'opacity: 0.5; color: red'`) → passed through VERBATIM so
85+
* Solid's `style()` helper applies it via `cssText` (LB6 SEAM 3);
8586
* - an already-built style object (a `$computed` returning a
8687
* CSS-custom-property map such as `{ '--rozie-slider-fill-start': '50%' }`,
8788
* or a plain style object) → passed through verbatim;
8889
* - `null` / `undefined` → `{}`.
8990
*
90-
* Returning `JSX.CSSProperties` keeps the value assignable to Solid's `style`
91-
* JSX prop, whose index signature permits CSS custom properties (`--x`).
92-
* Returns `{}` for empty, whitespace-only, or unparseable input — never throws
93-
* (see crash-safety note above).
91+
* The return type `string | JSX.CSSProperties` is exactly Solid's `style` JSX
92+
* prop type, so the emitted `style={parseInlineStyle(<expr>)}` is well-typed.
93+
* Returns `{}` for empty / whitespace-only input — never throws.
9494
*/
9595
export function parseInlineStyle(
9696
value: string | JSX.CSSProperties | Record<string, string | number> | null | undefined,
97-
): JSX.CSSProperties {
97+
): JSX.CSSProperties | string {
9898
if (value == null) return {};
9999
// Already an object (e.g. a CSS-custom-property map from a `$computed`) —
100100
// pass through; Solid's `style` prop accepts custom properties.
101101
if (typeof value === 'object') return value as JSX.CSSProperties;
102102
if (value.length === 0 || /^\s*$/.test(value)) return {};
103-
try {
104-
return styleToJS(value, { reactCompat: true }) as JSX.CSSProperties;
105-
} catch {
106-
return {};
107-
}
103+
// A CSS string — hand it to Solid's `style()` runtime, which sets it via
104+
// `node.style.cssText`. The browser's CSS parser applies every (kebab-case)
105+
// declaration correctly, including multi-word props like `padding-left` that
106+
// a camelCased `setProperty` key would silently drop.
107+
return value;
108108
}

0 commit comments

Comments
 (0)