Skip to content

fix(core): restore markdown shortcuts and text substitutions after autoformat deprecation#4983

Merged
zbeyens merged 2 commits into
mainfrom
fix/autformat
May 27, 2026
Merged

fix(core): restore markdown shortcuts and text substitutions after autoformat deprecation#4983
zbeyens merged 2 commits into
mainfrom
fix/autformat

Conversation

@bbyiringiro
Copy link
Copy Markdown
Collaborator

@bbyiringiro bbyiringiro commented May 27, 2026

  • Auto release

Closes #4968

Checklist

  • pnpm typecheck
  • pnpm lint:fix
  • bun test
  • pnpm brl
  • pnpm changeset

Summary

This PR fixes two regressions introduced with the @platejs/autoformat → per-plugin inputRules migration in v53. Either alone is enough to make the examples in docs feel broken; together they explain every variant of "shortcuts stopped working" being reported.

  1. createTextSubstitutionInputRule mis-derives the trigger character. Inline patterns like ->, (c)©, arrows, fractions, sub/superscripts all silently no-op on every keystroke.
  2. resolvePlugin mutates the user's plugin config object. Any second editor instance built from the same kit array (StrictMode remount, HMR, multi-editor page) loses every .configure({ inputRules: [...] }) rule — heading/list/blockquote/mark shortcuts stop firing entirely.

Bug 1 - text substitutions never fire on the right trigger

Where: packages/core/src/lib/plugins/input-rules/createInputRules.ts (getTextSubstitutionMatchRange)

Root cause: the v53 rewrite collapsed v52's { end: match, start: '' } non-paired branch and the paired single-char branch into one path that always reverses the match string. For non-paired patterns the slice math then inverts:

// v53 (buggy)
const reversed = match.split('').reverse().join('');
const triggers = trigger ? ... : [reversed.slice(-1)]; // FIRST char of match
return { end: trigger ? reversed : reversed.slice(0, -1), ... }; // LAST char of match

For match: '->' the trigger becomes '-' and the search string becomes '>', so the rule registers under the wrong key and, even when it fires, searches for > in the doc before the cursor — which can never be there because the user just typed it. Single-char paired patterns (smart quotes) accidentally worked because reversed === match collapses both branches to the same result.

Fix: drop the reversal entirely.

const triggers = trigger ? ... : [match.slice(-1)];           // last char triggers
return { end: trigger ? match : match.slice(0, -1), start: match, triggers };

Then mirror the existing end: isPaired ? '' : end override with start: isPaired ? start : '' in resolveTextSubstitution, so non-paired patterns don't try to find an opening delimiter that doesn't exist.

Drive-by perf refactor (in the same function): resolveTextSubstitution was rebuilding the { end, start, triggers } tuple for every pattern on every keystroke (~50 patterns in AutoformatKit), then doing a linear triggers.includes(text) scan to filter. The result is deterministic per pattern, so I lift it into compilePatternsByTrigger(patterns) once at rule construction:

Before After
Per-keystroke work O(P) string allocs + scans, where P = total patterns across the rule O(1) Map.get(text) + iterate only patterns whose trigger equals text
Branching Array.isArray(pattern.format) ? ... : ... twice per pattern per keystroke once per pattern at construction
Allocations on insertText one tuple object + one triggers array per pattern none
Memory at rest unchanged one Map<string, CompiledPattern[]> per rule + one CompiledPattern per match string

The Map lookup also means typing any character that isn't a trigger short-circuits to zero work, instead of iterating every pattern.

Bug 2 - .configure({ inputRules }) loses its rules on second resolve

Where: packages/core/src/internal/plugin/resolvePlugin.ts

Root cause: plugin.configure(config) stores a closure that returns the user's config object by reference:

// createSlatePlugin.ts:148
newPlugin.__configuration = (ctx) =>
  isFunction(config) ? config(ctx) : config; // same `config` ref every call

Then resolvePlugin consumes inputRules by mutating that exact object:

// resolvePlugin.ts (before)
const configResult = plugin.__configuration(...);
if (configResult.inputRules !== undefined) {
  (plugin as any).__configuredInputRules = [...];
  configResult.inputRules = undefined; // ← writes back into the user's config
}

On the first editor creation, __configuredInputRules is populated correctly. On the second editor creation using the same kit array, the closure returns the same config object — but now its inputRules is undefined, the if is skipped, and the plugin gets no configured rules. Verified with a Proxy and direct property inspection:

BEFORE editor 1: __configuration() returns { inputRules: [...1] }
AFTER  editor 1: __configuration() returns { inputRules: undefined }  ← mutation

This is what the user observed on /editors: the AI block iframe mounts twice (React StrictMode + iframe load behavior), so the second editor it builds from EditorKit has zero configured rules for h1..h6, blockquote, bold, italic, list, etc. The basic editor next to it only mounts once, so its rules survive.

Fix: rest-destructure instead of mutating.

const rawConfigResult = plugin.__configuration(...) as any;
// Copy before mutating: the user's config object is captured by closure
// and reused across editor instances, so mutating it would clear
// inputRules on subsequent resolutions.
const { inputRules: configInputRules, ...configResult } = rawConfigResult;

if (configInputRules !== undefined) {
  (plugin as any).__configuredInputRules = [
    ...normalizeConfiguredInputRules((plugin as any).__configuredInputRules),
    ...normalizeConfiguredInputRules(configInputRules),
  ];
}

plugin = mergePlugins(plugin, configResult);

Same allocation cost as the previous code (which already created configResult), zero added work in the steady state. Local copy is the only inputRules reference we mutate; the user's object stays intact.

@bbyiringiro bbyiringiro requested a review from a team May 27, 2026 00:00
@codesandbox
Copy link
Copy Markdown

codesandbox Bot commented May 27, 2026

Review or Edit in CodeSandbox

Open the branch in Web EditorVS CodeInsiders

Open Preview

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 27, 2026

🦋 Changeset detected

Latest commit: ccc4c21

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@platejs/core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. bug Something isn't working patch Bugfix & documentation PR plugin:autoformat labels May 27, 2026
@felixfeng33
Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@zbeyens zbeyens merged commit 70a385f into main May 27, 2026
3 checks passed
@zbeyens zbeyens deleted the fix/autformat branch May 27, 2026 08:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working patch Bugfix & documentation PR plugin:autoformat size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Autoformat examples stopped working

3 participants