Skip to content

Add dynamic RTL CSS loading support for webpack bundles#14479

Open
rtibbles wants to merge 2 commits intolearningequality:release-v0.19.xfrom
rtibbles:claude/rtl-dynamic-loading-011CUuNvu6QLYGJdquD6gQmq
Open

Add dynamic RTL CSS loading support for webpack bundles#14479
rtibbles wants to merge 2 commits intolearningequality:release-v0.19.xfrom
rtibbles:claude/rtl-dynamic-loading-011CUuNvu6QLYGJdquD6gQmq

Conversation

@rtibbles
Copy link
Copy Markdown
Member

Summary

Enable webpack CSS code splitting to work with RTL by replacing the manual scriptLoader-based CSS swapping with a webpack runtime integration. An RTLManager module exposed on the core app tracks per-bundle RTL state, and the RTL webpack plugin injects a miniCssF intercept into each bundle's runtime so dynamically loaded CSS chunks automatically resolve to .rtl.css variants when needed.

References

Closes #13536

Reviewer guidance

  • Testing requires pnpm devserver (not devserver-hot) since HMR uses style-loader which bypasses MiniCssExtractPlugin. To verify CSS code splitting works, convert a route component to an async import. For example, in kolibri/plugins/learn/frontend/routes/index.js:
    -import BookmarkPage from '../views/BookmarkPage.vue';
    +const BookmarkPage = () => import('../views/BookmarkPage.vue');
    Then navigate to the bookmarks page and confirm the CSS chunk loads correctly.
  • The miniCssF intercept in webpackRtlPlugin.js resolves the kolibri/rtlcss module ID differently for core vs plugin bundles — core looks up the real webpack module ID at code generation time, plugins use the window global. Worth verifying both paths in the generated output.
  • reloadBundleCSS in rtlcss.js uses double requestAnimationFrame instead of onload for CSS swap timing — this only covers link elements tagged with data-webpack-bundle, which currently only applies to async CSS chunks loaded by MiniCssExtractPlugin's runtime.
  • Content viewers that call loadDirectionalCSS must destructure contentDirection from useContentViewer in their setup(). Currently only Perseus and EPUB do this — Perseus was missing it and is fixed in this PR.

AI usage

Implemented with Claude Code. Debugged the content viewer loading stall by driving a browser to reproduce, then tracing the promise chain to find the root cause.

@github-actions github-actions bot added DEV: renderers HTML5 apps, videos, exercises, etc. DEV: frontend SIZE: large labels Mar 31, 2026
@rtibbles rtibbles force-pushed the claude/rtl-dynamic-loading-011CUuNvu6QLYGJdquD6gQmq branch from 34f0b26 to 5280e8b Compare March 31, 2026 18:29
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 31, 2026

@rtibbles rtibbles marked this pull request as ready for review March 31, 2026 18:41
@rtibbles rtibbles requested a review from rtibblesbot March 31, 2026 19:36
Copy link
Copy Markdown
Contributor

@rtibblesbot rtibblesbot left a comment

Choose a reason for hiding this comment

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

Solid approach to enabling RTL-compatible CSS code splitting via webpack runtime integration. The architecture — core RTLManager singleton, per-bundle registration, miniCssF intercept, STAGE_ATTACH ordering — is well thought out.

CI passing. No UI verification needed (the only .vue change adds contentDirection to Perseus's setup() destructuring — a data flow fix, not visual).

  • blocking: Race condition in reloadBundleCSSonerror and double-rAF fire independently, so a load failure still removes the old CSS (see inline)
  • suggestion: _findRtlModuleId returning null produces __webpack_require__(null) at runtime (see inline)
  • suggestion: Duplicate runtime-ordering tests (see inline)
  • suggestion: loadDirectionalCSS no longer swaps initial CSS loaded via scriptLoader for content viewers (see inline)
  • praise: Excellent explanatory comments throughout, especially the STAGE_ATTACH ordering rationale and createCssInsert serialization workaround

@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Reviewed the pull request diff checking for:

  • Correctness: bugs, edge cases, undocumented behavior, resource leaks, hardcoded values
  • Design: unnecessary complexity, naming, readability, comment accuracy, redundant state
  • Architecture: duplicated concerns, minimal interfaces, composition over inheritance
  • Testing: behavior-based assertions, mocks only at hard boundaries, accurate coverage
  • Completeness: missing dependencies, unupdated usages, i18n, accessibility, security
  • Principles: DRY (same reason to change), SRP, Rule of Three (no premature abstraction)
  • Checked CI status and linked issue acceptance criteria
  • For UI changes: inspected screenshots for layout, visual completeness, and consistency

link.remove();
resolve();
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

blocking: Race condition between onerror and the double requestAnimationFrame.

If the new CSS fails to load:

  1. onerror fires → removes newLink, calls reject
  2. The already-queued rAF callbacks still fire → link.remove() removes the old CSS, resolve() is ignored (promise settled)

Result: both old and new CSS are removed, leaving no styles. The comment on line 104 says "keep old CSS if new one fails" but the implementation doesn't achieve that.

Fix: gate the rAF callback behind a flag that onerror clears, or cancel the rAF. For example:

let errored = false;
newLink.onerror = () => {
  errored = true;
  newLink.remove();
  reject(new Error(`Failed to load ${newHref}`));
};
requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    if (!errored) {
      link.remove();
      resolve();
    }
  });
});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed. Added an errored flag that gates the rAF callback — if onerror fires first, the old CSS link is preserved.

return this.compilation.chunkGraph.getModuleId(module);
}
}
return null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: _findRtlModuleId returns null if no module has rawRequest === 'kolibri/rtlcss'. The generated code becomes __webpack_require__(null), which would fail at runtime. This shouldn't happen in practice (the module is in apiSpec.js), but a build-time error or warning here would surface misconfiguration early rather than producing a cryptic runtime failure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

_findRtlModuleId now pushes a WebpackError compilation warning when the module is not found. This surfaces the misconfiguration at build time. A hard throw would break test compilations that legitimately lack the module, and the runtime __webpack_require__(null) still fails loudly if it ever reaches production.

const bundleName = contentViewerModule.name;

if (direction === languageDirections.RTL) {
return rtlManager.enableRTL(bundleName);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: The new loadDirectionalCSS delegates entirely to rtlManager, which only operates on <link> elements tagged with data-webpack-bundle. But content viewer initial CSS is loaded via scriptLoader (lines 398 above), which doesn't add that attribute.

So if a content viewer needs to switch direction after initial load (e.g., RTL content in an LTR UI), the initial bundle CSS won't be swapped — only dynamically loaded chunks will be. The old implementation handled this by finding and replacing the initial CSS URL.

The PR description acknowledges this scope limitation. Can you confirm this is acceptable for the current use cases, or is there a plan to tag the initial CSS links as well?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No, this was a big oversight and a good catch, this has been reworked to properly make sure that the initial dynamically loaded CSS is properly handled.

});

describe('runtime module ordering', () => {
it('should order RTLCSSRuntimeModule to run after GetChunkFilenameRuntimeModule', async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: This test (lines 96–148) and the one at lines 265–348 in the integration with MiniCssExtractPlugin describe block both verify the same thing: RTL runtime module orders after the mini-css module. The integration block also creates its own tempDir that shadows the outer one. Consider consolidating into one test to reduce duplication and webpack compilation time.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Consolidated — removed the duplicate ordering test from the integration block and eliminated the shadowed tempDir.

// which defines miniCssF at STAGE_NORMAL (0). Runtime modules are sorted by
// stage first, then by identifier. Without this, "RTL CSS Loading" sorts before
// "get mini-css chunk filename" alphabetically and the miniCssF intercept fails.
super('RTL CSS Loading', STAGE_ATTACH);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

praise: The STAGE_ATTACH comment (lines 52–55) is excellent — clearly explains the non-obvious ordering dependency between runtime modules and why alphabetical sorting would break the miniCssF intercept. This is the kind of comment that prevents future regressions.

function createCssInsert(bundleId) {
// MiniCssExtractPlugin serializes functions with .toString(), losing closures.
// Use new Function to bake the bundleId into the function's source code.
return new Function(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

praise: Smart use of new Function to bake the bundleId as a string literal into the function body, working around MiniCssExtractPlugin's .toString() serialization. Well-documented constraint, and the test at line 460 in the spec file verifies the serialization roundtrip.

@rtibbles rtibbles force-pushed the claude/rtl-dynamic-loading-011CUuNvu6QLYGJdquD6gQmq branch from 5280e8b to 75c536c Compare March 31, 2026 20:42
claude added 2 commits March 31, 2026 14:07
Implements per-bundle RTL (Right-to-Left) CSS loading capability that allows
individual webpack bundles to dynamically switch between LTR and RTL CSS variants
without page reloads. This is essential for content viewers that need to display
RTL content within an LTR UI or vice versa.

Core RTL Manager (packages/kolibri/rtlcss.js):
- Singleton RTLManager class that tracks per-bundle RTL state
- Initializes bundle RTL state based on current global language direction
- CSS link swapping with double RAF for FOUC prevention
- Error handling for missing CSS files (keeps old CSS on failure)

WebpackRTLPlugin enhancements:
- Preserves existing RTL CSS generation via rtlcss
- Injects runtime code into each bundle via webpack RuntimeModule
- Differentiates between core and plugin bundles (isCoreBundle flag)
- Core bundles: access rtlManager via __webpack_require__
- Plugin bundles: access via window[kolibriName] externals
- Intercepts miniCssF to transform CSS chunk URLs based on RTL state
- Adds link tag label generation to MiniCssExtractPlugin to allow easy swapping of CSS files

Package updates:
- Added kolibri/rtlcss to package.json exports
- Added kolibri/rtlcss to apiSpec.js for external bundle access
- Pass isCoreBundle flag from webpack config to RTL plugin
Replaces the manual CSS link tracking and swapping logic with the new
RTL manager for content viewers. This removes several limitations:

Previous implementation:
- Only supported one CSS file per module (noted in code comments)
- Required manual DOM manipulation and tracking via urlTags
- Complex logic for finding and swapping CSS files

New implementation:
- Uses rtlManager.enableRTL/disableRTL with bundle name
- Handles multiple CSS files per bundle automatically
- More robust error handling via Promise-based API
- Removes ~40 lines of complex link tracking code

Changes in pluginMediator.js:
- Import rtlManager from kolibri/rtlcss
- Simplify loadDirectionalCSS to delegate to rtlManager
- Remove urlTags tracking in retrieveContentViewer
- Content viewers now benefit from automatic CSS direction switching

The kolibri-viewer package requires no changes as it delegates to
the mediator's loadDirectionalCSS method.
@rtibbles rtibbles force-pushed the claude/rtl-dynamic-loading-011CUuNvu6QLYGJdquD6gQmq branch from 75c536c to adf4f6c Compare March 31, 2026 21:08
Copy link
Copy Markdown
Contributor

@rtibblesbot rtibblesbot left a comment

Choose a reason for hiding this comment

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

All four prior findings addressed cleanly. The errored flag, WebpackError warning, test consolidation, and content viewer CSS rework all look correct.

Prior findings

Resolved:

  • Race condition in reloadBundleCSS (blocking) — errored flag added at rtlcss.js:156
  • _findRtlModuleId returning null (suggestion) — WebpackError warning pushed at webpackRtlPlugin.js:86-89
  • Duplicate runtime-ordering tests (suggestion) — consolidated, shadowed tempDir removed
  • loadDirectionalCSS not handling initial CSS (suggestion) — reworked with registerBundleCSS/loadRegisteredBundleCSS

4/4 prior findings resolved. 0 re-raised.

CI passing. No UI changes to verify (Perseus .vue change is a data-flow fix adding contentDirection to setup() destructuring).


@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly

How was this generated?

Compared the current PR state against findings from a prior review:

  • Retrieved prior bot reviews deterministically via the GitHub API
  • Classified each prior finding as RESOLVED, UNADDRESSED, ACKNOWLEDGED, or CONTESTED
  • Only raised NEW findings for newly introduced code
  • Reviewed the pull request diff checking for correctness, design, architecture, testing, completeness, and adherence to DRY/SRP principles
  • Checked CI status and linked issue acceptance criteria

link.href = href;
link.setAttribute('data-webpack-bundle', bundleId);
document.body.appendChild(link);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: Newly created links here are appended to document.body, while createCssInsert (used by MiniCssExtractPlugin for async chunks) appends to document.head. Both work — CSS applies regardless of DOM location — but the inconsistency could confuse someone debugging link element placement. Consider using document.head here for consistency, or adding a comment explaining why document.body is the right choice for content viewer CSS.

// Now run the deferred rAF callbacks — the errored flag should prevent old link removal
rAFCallbacks.forEach(cb => cb());

await expect(reloadPromise).rejects.toThrow('Failed to load');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

praise: This race condition test is well-constructed — collecting rAF callbacks without executing them, firing onerror first, then replaying the callbacks to verify the errored flag gates correctly. Good verification of the fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants