add react-jsx-dev-runtime to external#4
add react-jsx-dev-runtime to external#4mike-anderson wants to merge 1 commit intosuperdoc-dev:mainfrom
Conversation
modify the superdoc initailization guard to possibly work better under react 18 strict mode / dev mode test with yarn and npm
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
caio-pizzol
left a comment
There was a problem hiding this comment.
Thanks for tackling the React 18 Strict Mode issues @mike-anderson!
Overall the core changes look good - the abort pattern is well-implemented and solves a real problem. Just needs some cleanup to keep the PR focused.
| { | ||
| "name": "@superdoc-dev/esign", | ||
| "version": "1.3.0", | ||
| "version": "1.3.2", |
There was a problem hiding this comment.
No need to change version - semantic-release will bump patch this automatically (make sure to use fix: ... commit prefix)
| superdocRef.current = null; | ||
| }; | ||
| }, [document.source, document.mode, discoverAndApplyFields]); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps |
There was a problem hiding this comment.
The disable is acceptable here, but a brief comment explaining why would help future readers
There was a problem hiding this comment.
@caio-pizzol not sure your stance on this, but we could link a comment or PR for further depth. This was the "conversation" I had with Claude
The Problem with discoverAndApplyFields in the Dependency Array
Look at how discoverAndApplyFields is defined:
const discoverAndApplyFields = useCallback(
(editor: Editor) => {
// ... function body ...
},
[onFieldsDiscovered, updateFieldInDocument],
);This useCallback has dependencies on:
onFieldsDiscovered- a prop passed by the consumerupdateFieldInDocument- anotheruseCallback(which is stable with[]deps)
Here's the issue: If the parent component passes onFieldsDiscovered like this:
<SuperDocESign
onFieldsDiscovered={(fields) => console.log(fields)}
// ...
/>Every time the parent re-renders, onFieldsDiscovered is a new function reference. This causes:
discoverAndApplyFieldsto be recreated (new reference)- If
discoverAndApplyFieldswas in the effect's deps, the effect would re-run - SuperDoc gets destroyed and reinitialized on every parent re-render 💥
Why Removing It Is Safe
The function already uses fieldsRef.current internally:
fieldsRef.current.document?.forEach((f) => {
if (f.id) configValues.set(f.id, f.value);
});
fieldsRef.current.signer?.forEach((f) => {
if (f.value !== undefined) {
configValues.set(f.id, f.value);
}
});Because fieldsRef is a ref (not a dependency), the function always accesses the current field values regardless of when it was created.
What Actually Happens Now
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [document.source, document.mode]);- Effect only runs when
document.sourceordocument.modechanges discoverAndApplyFieldsis captured at the time the effect runs (closure)- The closure is "stale" in the sense that
onFieldsDiscoveredpoints to the version from that render - But this is fine because:
- Field data comes from
fieldsRef.current(always fresh) onFieldsDiscoveredis only called once during initialization anyway
- Field data comes from
Alternative Approach (More Complex)
If we wanted to be "correct" by React rules, we'd need to also ref-ify the callbacks:
const onFieldsDiscoveredRef = useRef(onFieldsDiscovered);
onFieldsDiscoveredRef.current = onFieldsDiscovered;
const discoverAndApplyFields = useCallback((editor: Editor) => {
// ...
onFieldsDiscoveredRef.current?.(discovered); // Use ref
}, []); // Now has no deps, always stableBut this adds complexity for little benefit—the current pattern is a pragmatic trade-off that's common in React codebases with async initialization.
The Timeline Assuming We Actually Change onFieldsDiscovered
1. Component mounts, effect runs
└─ Captures discoverAndApplyFields (v1) which has onFieldsDiscovered (v1) in its closure
2. async import("superdoc") is happening...
3. Parent re-renders with new onFieldsDiscovered (v2)
└─ Creates new discoverAndApplyFields (v2)
└─ BUT effect doesn't re-run, still has discoverAndApplyFields (v1)
4. SuperDoc finishes loading, onReady fires
└─ Calls discoverAndApplyFields(v1)
└─ Which calls onFieldsDiscovered(v1) ← THE OLD ONE
So yes, if the parent changes onFieldsDiscovered between mount and onReady firing, the old callback gets called.
Why this is probably acceptable:
-
onReadyfires once, early — typically within milliseconds of mount. The window for a parent re-render to happen before this is small. -
onFieldsDiscoveredis a "discovery" event — it's semantically a one-time notification. The parent is saying "tell me what fields you found." It's not an ongoing listener. -
In practice, parents rarely change these callbacks dynamically. They're usually stable references.
If you wanted to be bulletproof:
You'd ref-ify onFieldsDiscovered:
const onFieldsDiscoveredRef = useRef(onFieldsDiscovered);
useEffect(() => {
onFieldsDiscoveredRef.current = onFieldsDiscovered;
});
const discoverAndApplyFields = useCallback((editor: Editor) => {
// ...
onFieldsDiscoveredRef.current?.(discovered); // Always calls latest
}, [updateFieldInDocument]); // No longer depends on onFieldsDiscoveredThis would ensure the latest callback is always invoked, even if the parent updates it after mount but before onReady.
Trade-off decision
The current implementation prioritizes:
- Simplicity over theoretical correctness
- Preventing reinitializations (the bigger problem) over perfect callback freshness
Given that onFieldsDiscovered is a one-shot event that fires early, the stale closure risk is low. But if you're concerned about edge cases (e.g., slow network loading the SuperDoc bundle), the ref pattern above would eliminate it entirely.
| rollupOptions: { | ||
| external: ['react', 'react-dom', 'react/jsx-runtime', 'superdoc'], | ||
| }, | ||
| build: { |
There was a problem hiding this comment.
This reformats the entire file (quotes, indentation). Could we keep just the meaningful change?
hint: use husky locally for pre-commit hook or run lint/format script commands
There was a problem hiding this comment.
Not sure on this one - I just have my vscode set to auto-run the formatter for the project on save
|
@mike-anderson closing this one - fixes being addressed here: #7 |
PR: Fix React 18 Strict Mode Compatibility & Externalize JSX Dev Runtime
Summary
This PR addresses React 18 development mode compatibility issues and simplifies consuming application configurations by properly externalizing the JSX dev runtime.
Changes
1. Add
react/jsx-dev-runtimeto External DependenciesProblem: When consuming applications (like the demo) use this library in development mode, Vite's React plugin uses
react/jsx-dev-runtimefor JSX transformation instead ofreact/jsx-runtime. If onlyreact/jsx-runtimeis externalized, the dev runtime gets bundled into our library, causing React to be duplicated in the final bundle.Solution: By adding
react/jsx-dev-runtimeto our rollup externals:This ensures both production and development JSX runtimes are treated as peer dependencies, preventing React duplication regardless of build mode.
Benefit for consumers: The demo app's
vite.config.tsno longer needs theresolve.dedupe: ['react', 'react-dom', 'react/jsx-runtime']workaround. The config is now minimal:2. Refactor SuperDoc Initialization Guard for React 18 Strict Mode
Problem: React 18's Strict Mode intentionally double-invokes effects in development to help identify cleanup issues. The previous initialization pattern could cause race conditions where:
Solution: Implemented an abort pattern using a local
abortedflag:Key improvements:
onReadycallback checks the abort flag to prevent state updates after unmountdiscoverAndApplyFieldsfrom deps: This function is recreated on every render due to closure dependencies, causing unnecessary re-initializationTesting