Skip to content

feat: preloadLanguages accepts dynamic grammar imports - #193

Merged
AVGVSTVS96 merged 4 commits into
mainfrom
feat/dynamic-preload-languages
Aug 11, 2026
Merged

AVGVSTVS96 merged 4 commits into
mainfrom
feat/dynamic-preload-languages

Conversation

@AVGVSTVS96

Copy link
Copy Markdown
Owner

Summary

preloadLanguages now accepts shiki's LanguageInput entries (getters, promises, module objects) on the full and web bundles, so custom grammars can stay code-split instead of shipping in the main bundle:

const preload = [() => import("../langs/mcfunction.tmLanguage.json")];

<ShikiHighlighter language="mcfunction" preloadLanguages={preload}>
  {code}
</ShikiHighlighter>

Shiki's getSingletonHighlighter already resolves LanguageInput natively; this unblocks react-shiki's resolution layer:

  • New public PreloadLanguage = Language | LanguageInput type, exported from all three entries
  • Dynamic entries skip the string-match pool (they can't be matched until shiki resolves them), dedupe by reference (previously they'd all collapse to one o:undefined::undefined key), and pass the factory's loadability filter
  • The isDynamicLanguage guard narrows to Exclude<LanguageInput, LanguageRegistration> so the registration branch stays fully typed with no casts

Additive only

  • Strings and grammar objects behave exactly as before; all 22 prior language.test.ts tests untouched and passing
  • Core bundle unchanged: preloadLanguages remains a no-op with a custom highlighter
  • Ships as a patch via changeset

Testing

  • Unit: dynamic entries pass the loadability filter, pass through resolution, dedupe by reference
  • End-to-end: getter-loaded and module-promise-loaded grammars produce highlighted tokens
  • 125 tests pass, typecheck/build/attw/publint clean

Docs

README Preloading Languages section gains the dynamic import example with an IMPORTANT callout: define entries at module scope, since a fresh promise or arrow per render re-triggers highlighting.

Full/web bundles now take shiki LanguageInput entries (getters,
promises, module objects) in preloadLanguages, so custom grammars
stay code-split instead of shipping in the main bundle. Dynamic
entries bypass name matching, dedupe by reference, and resolve
inside shiki during highlighter setup. Additive only.
@changeset-bot

changeset-bot Bot commented Aug 11, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: da6628c

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

This PR includes changesets to release 1 package
Name Type
react-shiki 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

@vercel

vercel Bot commented Aug 11, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-shiki Ready Ready Preview Aug 11, 2026 7:05am

Comment thread package/README.md Outdated
```

> [!IMPORTANT]
> Define dynamic imports at module scope. A fresh `import()` promise or arrow function on every render re-triggers highlighting.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs accuracy (low): the "fresh promise re-triggers highlighting" claim doesn't hold for bare promises — only for functions.

useShikiHighlighter stabilizes options with useStableValue, which falls back to dequal. dequal compares two objects with the same constructor by their own enumerable keys — a Promise has none, so any two Promise instances compare equal. Functions, by contrast, hit dequal's strict-equality fallback and always compare unequal. Concretely:

  • A fresh arrow/getter per render → re-triggers highlighting (as documented ✅)
  • A fresh bare import() promise per render → treated as unchanged, no re-trigger (contradicts this callout, though the behavior is actually benign)
  • The sharper edge: intentionally replacing a bare-promise entry with a different promise (different grammar) is silently ignored — stableOpts keeps the first promise and the new grammar never loads. Same applies to the { default: grammar } module-object form (deep-equal → stable).

Since import() promises are the form this doc snippet's naming evokes, consider steering users to the getter form (() => import(...)) as the canonical dynamic entry — which the examples already do — and softening this callout to something like: "Define getters at module scope; a fresh arrow per render re-triggers highlighting. Bare promises are compared by type, so swapping one at runtime won't be picked up." Alternatively, languageKey could be taught to treat promises as unstable, but a docs fix seems proportionate to the edge.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified: dequal compares two distinct Promise instances as equal (same constructor, no own enumerable keys), while fresh arrows always compare unequal. Confirmed empirically against the shipped dequal:

  • dequal(Promise.resolve(1), Promise.resolve(2)) → true
  • dequal(() => 1, () => 1) → false
  • dequal({ default: grammarA }, { default: grammarA }) → true

So the callout's promise half was wrong, and the sharper edge (swapping a bare-promise entry at runtime being silently ignored) is real. Agreed a docs fix is proportionate — teaching languageKey to treat promises as unstable would defeat useStableValue for everyone using them correctly at module scope.

Fixed in 906ba98: the callout now steers to the getter form and notes that bare promises are compared by type, so runtime swaps won't be picked up.

Comment thread .changeset/dynamic-preload-languages.md
@claude

claude Bot commented Aug 11, 2026 •

Copy link
Copy Markdown

Review: preloadLanguages accepts dynamic grammar imports

Verdict: clean, well-typed, well-tested, and properly documented. Two low-severity notes left inline; neither blocks merge.

Checklist

  • ✅ Changeset included (.changeset/dynamic-preload-languages.md) — though see inline note on patch vs minor, since this adds new public API surface (PreloadLanguage export + widened prop contract)
  • ✅ Docs updated: README gains a dynamic-import example with the module-scope callout, and the preloadLanguages JSDoc in types.ts documents the new forms with an @example
  • ✅ Tests: unit coverage for the loadability filter, factory pass-through, and reference-based dedupe, plus two end-to-end hook tests (getter and module-promise forms) asserting actual highlighted tokens — the right level to test at
  • ✅ CI green: builds on Node 22/24/26, tests on React 18.3/19.2, lint/typecheck pass

Code quality

The implementation is nicely factored. isDynamicLanguage narrowing to Exclude<LanguageInput, LanguageRegistration> keeps the registration branch fully typed with no casts, and isRegistration composing its negation keeps the two classifications mutually exclusive by construction. Widening languageKey to return string | object so dynamic entries dedupe by reference in the same Set is an elegant fix for the previous o:undefined::undefined collapse, and the dedupeLanguages rewrite to filter is a genuine simplification. Delegating actual resolution of dynamic inputs to shiki's getSingletonHighlighter (rather than resolving them in react-shiki) is the right architectural call — the library stays a thin pass-through layer. The language.ts reordering (helpers → resolve → factory checks) inflates the diff but the final layout reads logically.

Findings (2, both low severity, inline)

  1. README callout is inaccurate for bare promises (comment): dequal (used by useStableValue) compares any two Promise instances as equal (same constructor, no own enumerable keys), so a fresh bare promise per render does not re-trigger highlighting — only fresh functions do. The benign side is fewer spurious re-highlights; the sharper edge is that intentionally swapping a bare-promise entry for a different one at runtime is silently ignored. A docs tweak steering users to the getter form (which the examples already use) seems proportionate.
  2. Changeset semver (comment): new backwards-compatible functionality + a new exported type reads as minor under semver and matches 0.10.0/0.11.0 precedent; flagged as a nit since the PR body indicates patch was deliberate.

Non-issues verified

  • Core bundle unaffected: with a custom highlighter the factory path is skipped entirely, so preloadLanguages remains a no-op there as documented.
  • Unknown string ids still fall back to plaintext: the factory's isLoadableLanguage filter drops the unmatched primary string while the dynamic entry passes through, and resolveLoadedLanguage then resolves by the grammar's registered name — the e2e tests exercise exactly this path.
  • Array-form LanguageInput entries and { default: ... } module objects are classified correctly by isDynamicLanguage; a plain registration object can't be misclassified unless it carries a default key, which TextMate grammars don't.

🤖 Reviewed by Fable 5
🔁 Re-review: add the fable-review or opus-review label

@AVGVSTVS96
AVGVSTVS96 merged commit 530d033 into main Aug 11, 2026
10 checks passed
@AVGVSTVS96
AVGVSTVS96 deleted the feat/dynamic-preload-languages branch August 11, 2026 21:29
@github-actions github-actions Bot mentioned this pull request Aug 11, 2026

This branch was successfully deployed

1 active deployment
Preview — da6628c6 Deployed Aug 11, 2026 by vercel[bot]
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.

1 participant