Skip to content

Tree-shake unused Subscription and ManagedResource lifecycle support #1597

Open

Description

@devinjameson

Objective

Make unused Subscription and ManagedResource lifecycle support disappear from a fresh production application without changing the public API, boot ordering, Message delivery, or cleanup behavior.

This is the next bundle-size investigation after #1581 and #1585. It is not implemented. The measurements below are retention experiments, not verified savings from a behavior-preserving patch.

Starting point and measured opportunity

The reviewed #1585 revision is 43fb5537aa49ed46e1a9fbc341d4685817de41d6. Its committed consumer baseline is:

Consumer Production gzip, level 9
Counter 81,277 B
Typing Game 112,626 B

The counter uses neither lifecycle feature. Typing Game uses Subscriptions, so it is an important guard against optimizing only an artificial empty program.

Historical probes:

Probe Before After Difference Limitation
Remove both runners and Model PubSub from parent 43f844cf744989a347a30c1ba7d7fee1a65949f2 85,704 B 80,856 B −4,848 B Intentionally breaks apps using lifecycle features
Same removal during an intermediate HTML-sharing worktree 80,909 B 76,040 B −4,869 B Intermediate source was not preserved; same behavior limitation
Keep a Subscription-owned runner while excluding ManagedResource support, using the intermediate Typing Game build 112,250 B 110,324 B −1,926 B Injection prototype had no behavioral verification and no ManagedResource implementation

These probes used an in-memory Vite/Rolldown write: false transform over the built module graph. The removal probe deleted the static runner imports, PubSub construction/publication, and both runner startup calls. It measured a dependency-retention opportunity only. The intermediate source states and a reusable experiment script are not preserved. Recreate the experiment on current compiled packages; do not subtract these historical deltas from a new baseline and call the result measured.

An additional Typing Game removal probe reached 107,880 B from 112,250 B by deleting its real Subscription behavior too. That number is invalid as an optimization and must not become a target claim.

Why these dependencies remain

Runtime statically imports forkSubscriptionFibers and forkManagedResourceFibers, allocates PubSub.unbounded, publishes every changed Model, and starts lifecycle fibers after the initial render.

The supporting modules are:

This makes lifecycle-related Stream, PubSub, and supporting Effect machinery reachable even when the configuration has no lifecycle entries. Moving code into another statically imported file will not fix that dependency graph.

Compatibility trap: a hook on the Subscription record is not sufficient

Subscriptions is an ordinary record of individually branded entries. The record itself is not a required branded object.

Supported composition includes selecting { Tick: all.Tick }, spreading records, and filtering entries through Object.entries / Object.fromEntries. A hook attached only to the original record is lost when selecting or reconstructing a record. A non-enumerable hook is also lost by spread.

Subscription.aggregate deliberately retains the entry objects. Existing tests cover reconstructed records with numeric and __proto__ keys. Do not silently disable these configurations or require consumers to stop composing records.

Candidate internal design, not an approved implementation

  1. Put a private, versioned lifecycle protocol in a small shared internal module. One possible key is a globally interned Symbol.for value, so two supported Foldkit copies can agree on the protocol without instanceof checks.
  2. Attach an enumerable lifecycle hook to every Subscription entry created by make and every new wrapper created by toLiftedEntry. Entry-object spread must preserve the hook; ordinary string-key enumeration must not expose it. persistent already goes through make; selection, filtering, and aggregation retain the entries.
  3. Move the Subscription runner behind that hook. Keep core Runtime free of value imports from the feature implementation. Keep runtime ownership explicit: Runtime still owns the Model, Message queue, Scope, startup, and teardown.
  4. Replace unconditional Model PubSub ownership in core with a small synchronous internal Model-listener registry. Each feature runner can own its unbounded queue/callback stream and register/unregister through Effect.acquireRelease. Listener callbacks must not let defects escape synchronous Message processing. Preserve delivery order and the current buffering semantics.
  5. Put a ManagedResource hook on the authentic identity returned by ManagedResource.tag, not on the resources record. Config composition retains config.resource; the runtime already creates one Ref per resource entry. Invoke the feature-owned runner for each config/Ref pair.
  6. Start the hooks at the current lifecycle startup point, after initial rendering but before init Commands and the boot buffer drain. Do not introduce dynamic imports or another asynchronous boot boundary.
  7. Specify cross-copy/protocol-version behavior explicitly. A missing or incompatible hook must not silently drop a lifecycle. Check compatibility with supported package-copy and peer-Effect configurations before relying on this mechanism.

The Subscription runner currently updates latestDependenciesRef before Stream.changesWith. Preserve that placement: a kept-alive Stream must read the latest dependencies even when equivalence suppresses a restart.

An empty record must remain inert. Multiple entries must retain independent fibers, state, and finalizers. Avoid introducing module cycles or a broad plugin API solely for this optimization.

Verification and acceptance

  • Preserve direct entry selection, record spread, entry spread, entries/fromEntries filtering, aggregate, lift, empty records, numeric keys, and __proto__ keys. Cover both lifted keep-alive branches.
  • Verify every active Subscription observes changed Models in order without duplicate or dropped delivery. Verify keep-alive reads, restart behavior, services, failure handling, and disposal.
  • Retain bootModelChanges.test.ts semantics: an OnMount-driven Model change must reach lifecycle subsystems before queued init-command results drain.
  • Cover ManagedResource acquire, reacquire, release, acquire-error Messages, release defects, Ref clearing, onReleased, explicit release/finalizer ordering, tag identity, multiple entries, and runtime disposal.
  • Verify registration is interruption-safe, every listener is removed on disposal, and no post-disposal delivery occurs.
  • Test supported duplicate-package/protocol scenarios. A structural symbol design alone is not evidence of compatibility.
  • Add or use real no-feature, Subscription-only, ManagedResource-only, and combined consumer fixtures. Typing Game must keep its actual Subscription behavior.
  • Run the existing hydration and opted-in recording probes so this change does not restore their retention in fresh builds or strip supported behavior.
  • Mutation-check any new structural or behavior gates, and compare rendering/startup behavior in a controlled same-batch run.
  • Establish a new measured baseline, inspect retained modules, and require a meaningful application-level saving before opening a core PR. A sub-80 KB gzip counter is a useful target, not an already-achieved result.

Reproduce bundle measurements

Use the committed bundle harness:

pnpm --filter @foldkit/ui... build
pnpm profile:bundle-size
pnpm check:bundle-size

It builds consumers through published exports using Vite 8, Oxc, ES2022, and the Foldkit plugin. It reports per-asset gzip level 9 and Brotli quality 11, with initial and deferred assets separate. Use emitted asset totals for claims; Rolldown module renderedLength is a lead for investigation, not an additive or compressed-byte attribution.

Two negative leads worth retaining: changing Effect namespace imports to direct subpath imports saved zero in the investigated graph; the Schema-only fixture already retains some Cookies/dateTime code, so those modules are not solely a removable core Runtime cost.

Route/feature code splitting is the separate architectural track in #1559. Runtime file organization is tracked in #1225; this issue is specifically about dependency retention.

AI-generated by Sol (Codex), posted at the user's request.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Planned: important after P1enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions