JS playground: .prettierrc canonicalizes user content immediately on successful parse (mid-edit surprise) #81

Closed
opened 2026-05-06 05:16:36 +00:00 by spikerj · 1 comment
Owner

Surface

LanguageRunner.ngOnInit (libraries/tools/src/components/language-runner/language-runner.ts), the two effect() blocks gated on config.injectPrettierRc that implement the bidirectional .prettierrcPrettierConfigStore binding (Gitea #36).

Behavior

In the JavaScript playground (advanced sandbox mode), when the user edits the synthetic .prettierrc file in the editor:

  1. User opens .prettierrc containing '{"tabWidth":4}' (the canonical form last written by the store→file effect).
  2. User pretty-prints it manually: '{"tabWidth":4,\n "semi":false}'. Valid JSON, intentionally hand-formatted.
  3. The file→store effect fires. prettierStore.fromJson(...) succeeds. lastPrettierRcContent is set to the literal user content (per the in-code comment, intentionally — to keep the panel→file canonicalization path working).
  4. The store changes ({tabWidth:4}{tabWidth:4, semi:false}).
  5. The store→file effect re-fires. prettierStore.toJson() returns canonical JSON (different from the user's literal). existing.content !== json is true; existing.content === lastPrettierRcContent is true (we just set them equal). So the file is upserted with the canonical form.
  6. The user sees their hand-formatted JSON replaced with canonical one-line JSON immediately on the keystroke that completed the parse. Monaco's writeValue re-runs setValue which moves the cursor to the start of the model.

Net effect: any successful .prettierrc edit is auto-reformatted under the user's cursor.

Why this exists

The author was aware of the loop-prevention problem and chose to track the literal user content (rather than the canonical) so the panel→file path keeps working. The trade-off was conscious — see the comment block at lines ~488–506 of language-runner.ts. But the trade-off has a UX cost that may not have been considered: panel-driven canonicalization happens NOT only on the next panel push, but ALSO immediately on the very same keystroke that just succeeded in parsing.

Suggested fix

Replace the equality-based loop-prevention with an explicit source-of-change tag:

private lastWriteOrigin: 'panel' | 'file' | 'init' = 'init';

// store→file effect:
if (this.lastWriteOrigin === 'file') {
    this.lastWriteOrigin = 'panel'; // reset for next iteration
    return;
}
this.lastWriteOrigin = 'panel';
this.upsertPrettierRcFile(json);

// file→store effect:
if (this.lastWriteOrigin === 'panel') {
    this.lastWriteOrigin = 'file';
    return;
}
this.lastWriteOrigin = 'file';
const ok = this.prettierStore.fromJson(file.content);

This lets the user's hand-formatted JSON survive intact in the file. The store stays in sync via fromJson. The panel-driven path still canonicalizes because the panel push originates with lastWriteOrigin='panel' from outside both effects.

Severity

Yellow — the binding works correctly; this is a UX surprise, not a bug. Power users expect their typed JSON to survive editing. Consider this as a follow-up to #36 once the binding has shipped and we have user feedback on the editing experience.

Acceptance

  • A user can hand-format .prettierrc JSON (multi-line, custom whitespace, reordered keys) and have it survive successful parses.
  • The visual options panel still rewrites .prettierrc to canonical JSON when used.
  • A spec proves: typing valid JSON into .prettierrc does not change the file's content beyond what the user typed.
## Surface `LanguageRunner.ngOnInit` (`libraries/tools/src/components/language-runner/language-runner.ts`), the two `effect()` blocks gated on `config.injectPrettierRc` that implement the bidirectional `.prettierrc` ↔ `PrettierConfigStore` binding (Gitea #36). ## Behavior In the JavaScript playground (advanced sandbox mode), when the user edits the synthetic `.prettierrc` file in the editor: 1. User opens `.prettierrc` containing `'{"tabWidth":4}'` (the canonical form last written by the store→file effect). 2. User pretty-prints it manually: `'{"tabWidth":4,\n "semi":false}'`. Valid JSON, intentionally hand-formatted. 3. The file→store effect fires. `prettierStore.fromJson(...)` succeeds. `lastPrettierRcContent` is set to the **literal user content** (per the in-code comment, intentionally — to keep the panel→file canonicalization path working). 4. The store changes (`{tabWidth:4}` → `{tabWidth:4, semi:false}`). 5. The store→file effect re-fires. `prettierStore.toJson()` returns canonical JSON (different from the user's literal). `existing.content !== json` is true; `existing.content === lastPrettierRcContent` is true (we just set them equal). So the file is **upserted with the canonical form**. 6. The user sees their hand-formatted JSON replaced with canonical one-line JSON immediately on the keystroke that completed the parse. Monaco's `writeValue` re-runs `setValue` which moves the cursor to the start of the model. Net effect: any successful `.prettierrc` edit is auto-reformatted under the user's cursor. ## Why this exists The author was aware of the loop-prevention problem and chose to track the literal user content (rather than the canonical) so the panel→file path keeps working. The trade-off was conscious — see the comment block at lines ~488–506 of `language-runner.ts`. But the trade-off has a UX cost that may not have been considered: panel-driven canonicalization happens NOT only on the next panel push, but ALSO immediately on the very same keystroke that just succeeded in parsing. ## Suggested fix Replace the equality-based loop-prevention with an explicit source-of-change tag: ```ts private lastWriteOrigin: 'panel' | 'file' | 'init' = 'init'; // store→file effect: if (this.lastWriteOrigin === 'file') { this.lastWriteOrigin = 'panel'; // reset for next iteration return; } this.lastWriteOrigin = 'panel'; this.upsertPrettierRcFile(json); // file→store effect: if (this.lastWriteOrigin === 'panel') { this.lastWriteOrigin = 'file'; return; } this.lastWriteOrigin = 'file'; const ok = this.prettierStore.fromJson(file.content); ``` This lets the user's hand-formatted JSON survive intact in the file. The store stays in sync via `fromJson`. The panel-driven path still canonicalizes because the panel push originates with `lastWriteOrigin='panel'` from outside both effects. ## Severity Yellow — the binding works correctly; this is a UX surprise, not a bug. Power users expect their typed JSON to survive editing. Consider this as a follow-up to #36 once the binding has shipped and we have user feedback on the editing experience. ## Acceptance - A user can hand-format `.prettierrc` JSON (multi-line, custom whitespace, reordered keys) and have it survive successful parses. - The visual options panel still rewrites `.prettierrc` to canonical JSON when used. - A spec proves: typing valid JSON into `.prettierrc` does not change the file's content beyond what the user typed.
Author
Owner

Fixed in spikersoft-angular PR #56. Replaced the equality-based loop-prevention's failure mode with a one-shot prettierRcStoreChangeFromFile flag: the file→store effect raises it on a successful user-edit parse, and the store→file effect consumes it to skip the canonical rewrite, preserving the user's hand-formatted text. Panel-driven changes leave the flag false, so panel→file canonicalization is unchanged. Two new specs cover (a) hand-formatted JSON surviving a successful parse and (b) a later panel change still canonicalizing. Will close once PR #56 merges.

Fixed in `spikersoft-angular` PR #56. Replaced the equality-based loop-prevention's failure mode with a one-shot `prettierRcStoreChangeFromFile` flag: the file→store effect raises it on a successful user-edit parse, and the store→file effect consumes it to skip the canonical rewrite, preserving the user's hand-formatted text. Panel-driven changes leave the flag false, so panel→file canonicalization is unchanged. Two new specs cover (a) hand-formatted JSON surviving a successful parse and (b) a later panel change still canonicalizing. Will close once PR #56 merges.
Sign in to join this conversation.