-
Notifications
You must be signed in to change notification settings - Fork 0
Description
What happens
In src/story-init.ts, executeStoryInit() renders the StoryInit passage into a detached DOM container using Preact's render(), then immediately unmounts it by calling render(null, container) on the very next line:
const container = document.createElement('div');
render(
h(() => renderNodes(ast) as any, null),
container,
);
render(null, container); // ← unmounts instantlyThis means the entire Preact component tree for StoryInit is mounted and torn down synchronously in a single tick. Any component that schedules work via useEffect, setTimeout, setInterval, or other async mechanisms will have its cleanup function called immediately — before the effect body ever executes meaningful work.
This function is called during boot in src/index.tsx (line 102) as a synchronous step in the boot() sequence, with no delay or lifecycle awareness.
Why this is a problem
Preact's useEffect callbacks run asynchronously after render. The current code mounts the tree and unmounts it in the same synchronous call stack. This means:
- Effects never fire —
useEffectcallbacks are scheduled microtasks/batched updates that haven't executed by the timerender(null, container)tears down the tree. - Timers are cancelled before they trigger — Any
setTimeoutorsetIntervalset up in an effect will be cleared by the cleanup function that runs on unmount. - State updates are discarded — Any
useStatesetter called from an async callback targets a now-unmounted component.
The original design comment says "render all macros into a detached DOM node so their side effects fire through the normal Preact pipeline" — but the immediate unmount defeats this purpose for anything that isn't purely synchronous.
Impact on users
Any macro that relies on async effects will fail silently when used inside a StoryInit passage:
{timed}— UsesuseEffect+setTimeoutto reveal content after a delay. The timer is set and immediately cleared on unmount, so no timed content ever appears. (src/components/macros/Timed.tsx){repeat}— UsesuseEffect+setIntervalto cycle content. The interval is set and immediately cleared. (src/components/macros/Repeat.tsx){type}— UsesuseEffectfor its typewriter animation. Never gets to run.- Any custom macro using
useEffectfor initialization (e.g., fetching data, setting up event listeners, registering global handlers) will have its effects aborted. {do}blocks with async work — Any{do}macro that schedules deferred side effects will not complete.
This is particularly confusing because these macros work perfectly in normal passages — StoryInit silently swallows the failure with no error or warning.
Synchronous macros that DO work
Macros that perform their side effects synchronously during render (e.g., {set} which calls store.setState() during render) work correctly because their effects complete before the unmount call. This makes the bug harder to discover — authors may assume StoryInit works for all macros when it only works for synchronous ones.
Suggested fix
The core problem is that StoryInit needs to keep the Preact tree mounted long enough for effects to fire, but it also shouldn't remain mounted forever (it would interfere with the real UI).
Possible approaches:
-
Defer unmount to after effects settle — Use
requestAnimationFrameor a microtask (queueMicrotask/Promise.resolve().then(...)) to delay therender(null, container)call until after Preact's effect queue has flushed. This is the minimal fix but may need multiple frames for chained effects. -
Use Preact's
options.__c(commit) hook — Hook into Preact's commit phase to know when all effects have run, then unmount. More robust but relies on Preact internals. -
Skip rendering entirely for StoryInit — Since StoryInit is primarily used for
{set}macros (synchronous state initialization), consider walking the AST directly and only executing synchronous side-effect macros, with a documented limitation that async macros are not supported in StoryInit. This would be an intentional design constraint rather than a silent bug. -
Keep StoryInit mounted in a hidden container — Mount it in a persistent but hidden DOM node (e.g.,
display: none) and never unmount it. Effects run normally. The downside is the tree stays in memory, but StoryInit passages are typically small.
Option 4 is probably the simplest and most correct — it lets all macros work as expected with no timing hacks.