[Preview NG] Introduce hooks to manage preview communication#3495
[Preview NG] Introduce hooks to manage preview communication#3495jeremywiebe wants to merge 15 commits intomainfrom
Conversation
🗄️ Schema Change: No Changes ✅ |
|
Size Change: 0 B Total Size: 502 kB ℹ️ View Unchanged
|
🛠️ Item Splitting: No Changes ✅ |
npm Snapshot: PublishedGood news!! We've packaged up the latest commit from this PR (fafe8ac) and published it to npm. You Example: pnpm add @khanacademy/perseus@PR3495If you are working in Khan Academy's frontend, you can run the below command. ./dev/tools/bump_perseus_version.ts -t PR3495If you are working in Khan Academy's webapp, you can run the below command. ./dev/tools/bump_perseus_version.js -t PR3495 |
16904a3 to
818c218
Compare
d534a50 to
0419ad1
Compare
818c218 to
feb3f11
Compare
feb3f11 to
9b6a03d
Compare
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.
Tip: disable this comment in your organization's Code Review settings.
benchristel
left a comment
There was a problem hiding this comment.
Thanks for doing this! Our iframe communication code has been neglected for a long time.
I still need to do a thorough review of the tests. My overall thoughts at this point are that we're trying to use AI to redesign a big chunk of existing code, and I don't think AI is good at this type of work. Hopefully my inline comments demonstrate why.
It seems like a lot of things could be simplified if we rewrote this by hand with a similar architecture to what's proposed here. Happy to pair on it if you want!
| // iframeRef is intentionally excluded - it's a stable ref that shouldn't trigger re-runs | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps |
There was a problem hiding this comment.
iframeRef is intentionally excluded - it's a stable ref that shouldn't trigger re-runs
This hook can't guarantee that iframeRef is actually a stable ref, since the caller can pass anything with a current: HTMLIFrameElement property. I'd prefer to add iframeRef to the deps — if it is a stable reference, it won't trigger re-renders, and if it's not, we get predictable behavior (and no linter suppression).
| * The iframe's unique identifier (from data-id attribute). Use for | ||
| * debugging/logging, but not for message routing. |
There was a problem hiding this comment.
Why is it not okay to use the id for message routing?
There was a problem hiding this comment.
Actually, thinking about this again, I don't think that we need an id at all.
The usePreviewController hook accepts an iframe ref. That ref is what's used to send messages down into the iframe. Messages that are posted back to the parent are filtered against the hook's instance of the iframe ref (if (event.source !== iframeRef.current?.contentWindow) { ... }) so we already have filtering in both directions.
I also checked and we don't log this ID anywhere.
| React.useEffect(() => { | ||
| const iframe = window.frameElement as HTMLIFrameElement | null; | ||
| if (iframe == null) { | ||
| throw new Error( | ||
| "usePreviewPresenter must be used within an iframe", | ||
| ); | ||
| } | ||
|
|
||
| // ID is used for debugging/logging, not message routing | ||
| const id = iframe.dataset.id; | ||
| const mobile = iframe.dataset.mobile === "true"; | ||
| const lintGutter = iframe.dataset.lintGutter === "true"; | ||
|
|
||
| if (id == null) { | ||
| throw new Error( | ||
| "usePreviewPresenter could not identify its id from the hosting iframe", | ||
| ); | ||
| } | ||
|
|
||
| setIframeId(id); | ||
| setIsMobile(mobile); | ||
| setHasLintGutter(lintGutter); | ||
| }, []); |
There was a problem hiding this comment.
Does this need to be a useEffect? window.frameElement should always exist if this code is running in an iframe — we don't need to wait for any React rendering to happen before getting the dataset attributes.
I think we could get rid of the useState calls above and just return the values from this hook:
const iframe = window.frameElement as HTMLIFrameElement | null;
// ...
return {
data,
isMobile: iframe.dataset.mobile === "true",
hasLintGutter: iframe.dataset.lintGutter === "true",
id: iframe.dataset.id,
reportHeight,
reportLintWarnings,
};| } | ||
|
|
||
| // Handle content data | ||
| // Note: ID check is for extra validation/debugging; actual routing is by event.source |
There was a problem hiding this comment.
Note: ID check is for extra validation/debugging
Do we need the ID check, then? Routing by event.source is sufficient and correct. Also, there's no debugging code here, so it's not clear what that part of the comment refers to.
9f775ca to
c555bef
Compare
| * not by comparing ID strings. | ||
| */ | ||
| interface PreviewMessageWithId extends PreviewMessageBase { | ||
| id: string; |
There was a problem hiding this comment.
Based on Ben's question about why the ID shouldn't be used for routing, I realized that the "gating" by message source that we do in the message handlers is sufficient. The id is just an extra value that doesn't add any further safety and so is just extra work for no benefit.
…sePreviewClient hooks with tests
…ntroller/Presenter
…eRef to the deps list
…viewControllerResult
…reviewPresenterResult
…sing switch case)
3d272ea to
fafe8ac
Compare
Summary:
This PR is part of a series building a typed, hook-based preview system for the Perseus editor. The new system replaces the untyped
window.iframeDataStore+ rawpostMessage(string)communication with structured, validated message passing viausePreviewControllerandusePreviewPresenterhooks. The new system is being built alongside the old one — no existing behavior changes until the final PR in the series flips the switch.Previous PRs in this series:
Adds the two core hooks that implement the new preview communication protocol.
usePreviewController(iframeRef)is used on the editor/parent side. It sends sanitized preview data to an iframe via typedpostMessage, listens for height updates back, and filters incoming messages byevent.sourceto ensure they come from the correct iframe. Returns{sendData, height}.usePreviewPresenter()is used inside the preview iframe. It listens for content data from the parent, sends back height reports and data requests via the typed message protocol. Throws if used outside an iframe. Returns{data, reportHeight}.Both hooks use the message types and validators from #3474 and the data sanitizer from #3492. Purely additive — no existing behaviour is changed.
Issue: LEMS-3741
Test plan:
pnpm test packages/perseus-editor/src/preview/use-preview-controller.test.ts packages/perseus-editor/src/preview/use-preview-presenter.test.ts— 48 tests covering initialization, message sending/receiving, sanitization, event.source filtering, iframe validation, height reporting, error handling, and cleanup.