Skip to content

StoryInit unmounts immediately, aborting async effects #74

@rohal12

Description

@rohal12

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 instantly

This 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:

  1. Effects never fireuseEffect callbacks are scheduled microtasks/batched updates that haven't executed by the time render(null, container) tears down the tree.
  2. Timers are cancelled before they trigger — Any setTimeout or setInterval set up in an effect will be cleared by the cleanup function that runs on unmount.
  3. State updates are discarded — Any useState setter 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} — Uses useEffect + setTimeout to 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} — Uses useEffect + setInterval to cycle content. The interval is set and immediately cleared. (src/components/macros/Repeat.tsx)
  • {type} — Uses useEffect for its typewriter animation. Never gets to run.
  • Any custom macro using useEffect for 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:

  1. Defer unmount to after effects settle — Use requestAnimationFrame or a microtask (queueMicrotask / Promise.resolve().then(...)) to delay the render(null, container) call until after Preact's effect queue has flushed. This is the minimal fix but may need multiple frames for chained effects.

  2. 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.

  3. 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.

  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions